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,
}));
}),