From 1dbbe3e1e441756ae0f0d6c6e0a2615f90328dab Mon Sep 17 00:00:00 2001 From: sasha Date: Thu, 16 Apr 2026 19:15:23 +0300 Subject: [PATCH] feat(users-page): show openclaw channel + username columns Surfaces OpenClaw trace metadata on the Users page so channel (telegram, mattermost, ...) and a human-readable sender name appear alongside userId. Username prefers openclaw_sender_username, falls back to openclaw_sender_name, then openclaw_sender_label; never shows raw sender IDs. Values are derived from existing trace metadata in the ClickHouse traces table - no persistence-schema changes. Extends the existing getUserMetrics query to aggregate with anyIf(x != '') over the two new computed columns and propagates them through users.metrics tRPC and the Users table columns. Adds backend integration tests covering the coalesce priority and the null-metadata case. Refs: 2BB-284 --- .../shared/src/server/repositories/traces.ts | 20 ++++- .../server/users-ui-table.servertest.ts | 87 +++++++++++++++++++ web/src/pages/project/[projectId]/users.tsx | 47 ++++++++++ web/src/server/api/routers/users.ts | 2 + 4 files changed, 153 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/server/repositories/traces.ts b/packages/shared/src/server/repositories/traces.ts index a812a66e0030..8fb768043749 100644 --- a/packages/shared/src/server/repositories/traces.ts +++ b/packages/shared/src/server/repositories/traces.ts @@ -1232,7 +1232,9 @@ export const getUserMetrics = async ( sum(total_cost) as sum_total_cost, max(t.timestamp) as max_timestamp, min(t.timestamp) as min_timestamp, - count(distinct t.id) as trace_count + count(distinct t.id) as trace_count, + anyIf(t.openclaw_channel, t.openclaw_channel != '') as openclaw_channel, + anyIf(t.openclaw_username, t.openclaw_username != '') as openclaw_username FROM ( SELECT @@ -1266,7 +1268,13 @@ export const getUserMetrics = async ( t.user_id, t.project_id, t.timestamp, - t.environment + t.environment, + t.metadata['openclaw_channel'] as openclaw_channel, + coalesce( + nullIf(t.metadata['openclaw_sender_username'], ''), + nullIf(t.metadata['openclaw_sender_name'], ''), + nullIf(t.metadata['openclaw_sender_label'], '') + ) as openclaw_username FROM __TRACE_TABLE__ t FINAL WHERE @@ -1288,7 +1296,9 @@ export const getUserMetrics = async ( environment, sum_total_cost, max_timestamp, - min_timestamp + min_timestamp, + openclaw_channel, + openclaw_username FROM stats`; return measureAndReturn({ @@ -1327,6 +1337,8 @@ export const getUserMetrics = async ( obs_count: string; trace_count: string; sum_total_cost: string; + openclaw_channel: string | null; + openclaw_username: string | null; }>({ query: query.replaceAll("__TRACE_TABLE__", "traces"), params: input.params, @@ -1344,6 +1356,8 @@ export const getUserMetrics = async ( observationCount: Number(row.obs_count), traceCount: Number(row.trace_count), totalCost: Number(row.sum_total_cost), + openclawChannel: row.openclaw_channel || null, + openclawUsername: row.openclaw_username || null, })); }, }); diff --git a/web/src/__tests__/server/users-ui-table.servertest.ts b/web/src/__tests__/server/users-ui-table.servertest.ts index 6421795d67db..88f37a3f49f7 100644 --- a/web/src/__tests__/server/users-ui-table.servertest.ts +++ b/web/src/__tests__/server/users-ui-table.servertest.ts @@ -130,4 +130,91 @@ describe("getUserMetrics function", () => { totalCost: 125, // 50 + 75 }); }); + + it("surfaces openclaw_channel and coalesced openclaw sender identity", async () => { + const userId = uuidv4(); + const traceA = uuidv4(); + const traceB = uuidv4(); + + await createTracesCh([ + createTrace({ + id: traceA, + project_id: projectId, + user_id: userId, + metadata: { + openclaw_channel: "mattermost", + // username absent on this trace - falls back to name. + openclaw_sender_name: "@ziabinartem", + openclaw_sender_label: "@ziabinartem (pwanex3ne3fx58nr5oaxg4j54w)", + }, + }), + createTrace({ + id: traceB, + project_id: projectId, + user_id: userId, + metadata: { + openclaw_channel: "mattermost", + openclaw_sender_username: "ziabinartem", + }, + }), + ]); + + // One observation per trace is required for the inner join to return rows. + await createObservationsInClickhouse([ + createObservationObject({ + id: uuidv4(), + trace_id: traceA, + project_id: projectId, + type: "GENERATION", + }), + createObservationObject({ + id: uuidv4(), + trace_id: traceB, + project_id: projectId, + type: "GENERATION", + }), + ]); + + const result = await getUserMetrics(projectId, [userId], []); + expect(result).toHaveLength(1); + expect(result[0].openclawChannel).toBe("mattermost"); + // username resolves via coalesce priority - one trace has username, the + // other falls back to name. Either non-empty value is acceptable since + // anyIf does not guarantee ordering. + expect( + ["ziabinartem", "@ziabinartem"].includes( + result[0].openclawUsername ?? "", + ), + ).toBe(true); + }); + + it("returns null openclaw fields when trace metadata is missing", async () => { + const userId = uuidv4(); + const traceId = uuidv4(); + + await createTracesCh([ + createTrace({ + id: traceId, + project_id: projectId, + user_id: userId, + metadata: { + source: "API", + }, + }), + ]); + + await createObservationsInClickhouse([ + createObservationObject({ + id: uuidv4(), + trace_id: traceId, + project_id: projectId, + type: "GENERATION", + }), + ]); + + const result = await getUserMetrics(projectId, [userId], []); + expect(result).toHaveLength(1); + expect(result[0].openclawChannel).toBeNull(); + expect(result[0].openclawUsername).toBeNull(); + }); }); diff --git a/web/src/pages/project/[projectId]/users.tsx b/web/src/pages/project/[projectId]/users.tsx index 2cfdb2e9b23c..bcf040ccec8b 100644 --- a/web/src/pages/project/[projectId]/users.tsx +++ b/web/src/pages/project/[projectId]/users.tsx @@ -33,6 +33,8 @@ import { Badge } from "@/src/components/ui/badge"; type RowData = { userId: string; + channel?: string; + username?: string; environment?: string; firstEvent: string; lastEvent: string; @@ -303,6 +305,49 @@ const UsersTable = ({ isBetaEnabled }: { isBetaEnabled: boolean }) => { ) : undefined; }, }, + { + accessorKey: "channel", + header: "Channel", + id: "channel", + size: 120, + enableHiding: true, + headerTooltip: { + description: + "OpenClaw source channel (e.g. telegram, mattermost) derived from trace metadata.", + }, + cell: ({ row }) => { + const value: RowData["channel"] = row.getValue("channel"); + if (!userMetrics.isSuccess) { + return ; + } + return value ? ( + + {value} + + ) : null; + }, + }, + { + accessorKey: "username", + header: "Username", + id: "username", + size: 180, + enableHiding: true, + headerTooltip: { + description: + "Human-readable sender name from OpenClaw trace metadata (username / name / label in that order).", + }, + cell: ({ row }) => { + const value: RowData["username"] = row.getValue("username"); + if (!userMetrics.isSuccess) { + return ; + } + return value ?? null; + }, + }, { accessorKey: "environment", header: "Environment", @@ -444,6 +489,8 @@ const UsersTable = ({ isBetaEnabled }: { isBetaEnabled: boolean }) => { data: userRowData.rows?.map((t) => { return { userId: t.id, + channel: t.openclawChannel ?? undefined, + username: t.openclawUsername ?? undefined, environment: t.environment ?? undefined, firstEvent: t.firstTrace?.toLocaleString() ?? "No event yet", diff --git a/web/src/server/api/routers/users.ts b/web/src/server/api/routers/users.ts index b82e73690b75..ad6d5493180a 100644 --- a/web/src/server/api/routers/users.ts +++ b/web/src/server/api/routers/users.ts @@ -97,6 +97,8 @@ export const userRouter = createTRPCRouter({ totalObservations: BigInt(metric.observationCount), totalTraces: BigInt(metric.traceCount), sumCalculatedTotalCost: metric.totalCost, + openclawChannel: metric.openclawChannel, + openclawUsername: metric.openclawUsername, })); }),