From b0bbf38de1a85757e64494bd01b367a95a80ea13 Mon Sep 17 00:00:00 2001 From: sasha Date: Sun, 19 Apr 2026 23:05:57 +0300 Subject: [PATCH 1/3] feat(users-page): filter heartbeats + read openclaw metadata from events; add Model column Three observability gaps on downstream OpenClaw deployments (2BB-294/295/298): 1) LiteLLM's internal health-check probes create trace rows tagged `litellm-internal-health-check`. Those were leaking into the Users page counts and into the Traces view. Exclude them from the events-table user aggregations and from `getTracesGroupedByUsers` / `getTotalUserCount` / `getUserMetrics`. 2) The Users page already reads Channel + Username from `trace.metadata['openclaw_channel']` + coalesced `openclaw_sender_username/name/label`, but the events-table aggregation path (`getUserMetricsFromEventsTable`) didn't. Add the same projection there so the columns stay populated under the events-backed code path. 3) LiteLLM names traces `litellm-/`. Surface the model as a first-class sortable column in the Traces table, preferring `metadata.model` when present and falling back to the suffix of the trace name. --- .../shared/src/server/repositories/events.ts | 24 ++++++++++++++++- .../shared/src/server/repositories/traces.ts | 4 +++ web/src/components/table/use-cases/traces.tsx | 27 +++++++++++++++++++ web/src/server/api/routers/users.ts | 2 ++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/server/repositories/events.ts b/packages/shared/src/server/repositories/events.ts index 41c858b6a0e4..2eee0da0029d 100644 --- a/packages/shared/src/server/repositories/events.ts +++ b/packages/shared/src/server/repositories/events.ts @@ -2677,6 +2677,7 @@ export const getUsersFromEventsTable = async ( .where(appliedEventsFilter) .whereRaw("e.user_id IS NOT NULL AND length(e.user_id) > 0") .whereRaw("e.is_deleted = 0") + .whereRaw("NOT has(e.tags, 'litellm-internal-health-check')") .when(Boolean(searchQuery), (b) => b.whereRaw("e.user_id ILIKE {searchQuery: String}", { searchQuery: `%${searchQuery}%`, @@ -2723,6 +2724,7 @@ export const getUsersCountFromEventsTable = async ( AND e.user_id IS NOT NULL AND e.user_id != '' AND e.is_deleted = 0 + AND NOT has(e.tags, 'litellm-internal-health-check') ${appliedEventsFilter.query ? `AND ${appliedEventsFilter.query}` : ""} ${searchCondition} `; @@ -2774,13 +2776,27 @@ export const getUserMetricsFromEventsTable = async ( sumMap(e.usage_details) as sum_usage_details, sum(e.total_cost) as sum_total_cost, min(e.start_time) as min_timestamp, - max(e.start_time) as max_timestamp + max(e.start_time) as max_timestamp, + anyIf(mapFromArrays(arrayReverse(e.metadata_names), arrayReverse(e.metadata_values))['openclaw_channel'], mapFromArrays(arrayReverse(e.metadata_names), arrayReverse(e.metadata_values))['openclaw_channel'] != '') as openclaw_channel, + anyIf( + coalesce( + nullIf(mapFromArrays(arrayReverse(e.metadata_names), arrayReverse(e.metadata_values))['openclaw_sender_username'], ''), + nullIf(mapFromArrays(arrayReverse(e.metadata_names), arrayReverse(e.metadata_values))['openclaw_sender_name'], ''), + nullIf(mapFromArrays(arrayReverse(e.metadata_names), arrayReverse(e.metadata_values))['openclaw_sender_label'], '') + ), + coalesce( + nullIf(mapFromArrays(arrayReverse(e.metadata_names), arrayReverse(e.metadata_values))['openclaw_sender_username'], ''), + nullIf(mapFromArrays(arrayReverse(e.metadata_names), arrayReverse(e.metadata_values))['openclaw_sender_name'], ''), + nullIf(mapFromArrays(arrayReverse(e.metadata_names), arrayReverse(e.metadata_values))['openclaw_sender_label'], '') + ) IS NOT NULL + ) as openclaw_username `, }) .whereRaw("e.user_id IN ({userIds: Array(String)})", { userIds }) // not required if called from tRPC (user_id is always defined), left in for safety only .whereRaw("e.user_id IS NOT NULL AND length(e.user_id) > 0") .whereRaw("e.is_deleted = 0") + .whereRaw("NOT has(e.tags, 'litellm-internal-health-check')") .where(appliedEventsFilter); const { query: statsQuery, params: statsParams } = @@ -2796,6 +2812,8 @@ export const getUserMetricsFromEventsTable = async ( sum_total_cost, min_timestamp, max_timestamp, + openclaw_channel, + openclaw_username, arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'input') > 0, sum_usage_details))) as input_usage, arraySum(mapValues(mapFilter(x -> positionCaseInsensitive(x.1, 'output') > 0, sum_usage_details))) as output_usage, sum_usage_details['total'] as total_usage @@ -2813,6 +2831,8 @@ export const getUserMetricsFromEventsTable = async ( obs_count: string; trace_count: string; sum_total_cost: string; + openclaw_channel: string | null; + openclaw_username: string | null; }>({ query, params: statsParams, @@ -2835,6 +2855,8 @@ export const getUserMetricsFromEventsTable = 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/packages/shared/src/server/repositories/traces.ts b/packages/shared/src/server/repositories/traces.ts index 8fb768043749..afe5066b271b 100644 --- a/packages/shared/src/server/repositories/traces.ts +++ b/packages/shared/src/server/repositories/traces.ts @@ -790,6 +790,7 @@ export const getTracesGroupedByUsers = async ( WHERE t.project_id = {projectId: String} AND t.user_id IS NOT NULL AND t.user_id != '' + AND NOT has(t.tags, 'litellm-internal-health-check') ${tracesFilterRes?.query ? `AND ${tracesFilterRes.query}` : ""} ${search.query} GROUP BY user @@ -1186,6 +1187,7 @@ export const getTotalUserCount = async ( ${search.query} AND t.user_id IS NOT NULL AND t.user_id != '' + AND NOT has(t.tags, 'litellm-internal-health-check') `; return queryClickhouse({ @@ -1259,6 +1261,7 @@ export const getUserMetrics = async ( where user_id IN ({userIds: Array(String) }) AND project_id = {projectId: String } + AND NOT has(t.tags, 'litellm-internal-health-check') ${filter.length > 0 ? `AND ${chFilterRes.query}` : ""} ) ) as o @@ -1280,6 +1283,7 @@ export const getUserMetrics = async ( WHERE t.user_id IN ({userIds: Array(String) }) AND t.project_id = {projectId: String } + AND NOT has(t.tags, 'litellm-internal-health-check') ${filter.length > 0 ? `AND ${chFilterRes.query}` : ""} ) as t on t.id = o.trace_id and t.project_id = o.project_id diff --git a/web/src/components/table/use-cases/traces.tsx b/web/src/components/table/use-cases/traces.tsx index 659098861e9b..60291f4aadf0 100644 --- a/web/src/components/table/use-cases/traces.tsx +++ b/web/src/components/table/use-cases/traces.tsx @@ -610,6 +610,33 @@ export default function TracesTable({ return value ?? undefined; }, }, + { + accessorKey: "model", + header: "Model", + id: "model", + size: 150, + enableHiding: true, + enableSorting: false, + cell: ({ row }) => { + const metadata = row.original.metadata as + | { model?: unknown } + | null + | undefined; + const metadataModel = + metadata && typeof metadata.model === "string" + ? metadata.model + : undefined; + if (metadataModel) return metadataModel; + const name: TracesTableRow["name"] = row.getValue("name"); + if (typeof name === "string" && name.startsWith("litellm-")) { + const slashIdx = name.indexOf("/"); + if (slashIdx >= 0 && slashIdx < name.length - 1) { + return name.slice(slashIdx + 1); + } + } + return undefined; + }, + }, { accessorKey: "input", header: "Input", diff --git a/web/src/server/api/routers/users.ts b/web/src/server/api/routers/users.ts index ad6d5493180a..30d1945498f1 100644 --- a/web/src/server/api/routers/users.ts +++ b/web/src/server/api/routers/users.ts @@ -194,6 +194,8 @@ export const userRouter = createTRPCRouter({ totalObservations: BigInt(metric.observationCount), totalTraces: BigInt(metric.traceCount), sumCalculatedTotalCost: metric.totalCost, + openclawChannel: metric.openclawChannel, + openclawUsername: metric.openclawUsername, })); }), From e35a8d83c0f7197887d56116010456b146c7bf05 Mon Sep 17 00:00:00 2001 From: sasha Date: Sun, 19 Apr 2026 23:18:32 +0300 Subject: [PATCH 2/3] fix(users-page): apply heartbeat filter to hasAny* gates too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Codex review on #4: getUsersFromEventsTable / getUsersCountFromEventsTable / getUserMetricsFromEventsTable / getTracesGroupedByUsers / getTotalUserCount / getUserMetrics all skip rows tagged `litellm-internal-health-check`, but hasAnyUser and hasAnyUserFromEventsTable did not. In projects where heartbeat probes are the only tagged user rows, the onboarding gate saw "data exists" while the filtered list/metrics endpoints returned empty — an inconsistent empty state where the wizard stayed hidden on a seemingly-empty page. Mirror the same `NOT has(tags, 'litellm-internal-health-check')` predicate on both hasAny* queries so the gating logic lines up with the filtered datasets. --- packages/shared/src/server/repositories/events.ts | 7 ++++++- packages/shared/src/server/repositories/traces.ts | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/server/repositories/events.ts b/packages/shared/src/server/repositories/events.ts index 2eee0da0029d..c2ac2254a690 100644 --- a/packages/shared/src/server/repositories/events.ts +++ b/packages/shared/src/server/repositories/events.ts @@ -2867,7 +2867,11 @@ export const getUserMetricsFromEventsTable = async ( export const hasAnyUserFromEventsTable = async ( projectId: string, ): Promise => { - // Filter out deleted rows + // Filter out deleted rows + heartbeat probes so the "any user" gate lines + // up with the filtered table/metrics endpoints (see getUsersFromEventsTable, + // getUsersCountFromEventsTable, getUserMetricsFromEventsTable). Otherwise + // projects whose only tagged user rows are LiteLLM health-checks would + // report "data exists" while the list endpoint returns zero. const query = ` SELECT 1 FROM events_core @@ -2875,6 +2879,7 @@ export const hasAnyUserFromEventsTable = async ( AND user_id IS NOT NULL AND user_id != '' AND is_deleted = 0 + AND NOT has(tags, 'litellm-internal-health-check') LIMIT 1 `; diff --git a/packages/shared/src/server/repositories/traces.ts b/packages/shared/src/server/repositories/traces.ts index afe5066b271b..8a8c169c848c 100644 --- a/packages/shared/src/server/repositories/traces.ts +++ b/packages/shared/src/server/repositories/traces.ts @@ -1121,12 +1121,18 @@ export const hasAnyUser = async (projectId: string) => { }, }, fn: async (input) => { + // The heartbeat-tag filter mirrors the one on getUsersCountFromEventsTable + // and getTracesGroupedByUsers — without it, projects whose only tagged + // user rows are LiteLLM health-check probes would report "data exists" + // here while the filtered table/metrics endpoints return zero rows, + // leaving the onboarding wizard permanently hidden. const query = ` SELECT 1 FROM traces WHERE project_id = {projectId: String} AND user_id IS NOT NULL AND user_id != '' + AND NOT has(tags, 'litellm-internal-health-check') LIMIT 1 `; From 863479ef26c6944391eff9ad4b9b9625e7000009 Mon Sep 17 00:00:00 2001 From: sasha Date: Mon, 20 Apr 2026 00:15:14 +0300 Subject: [PATCH 3/3] fix(traces-table): drop dead metadata.model lookup in Model column Addresses Codex P2 on #4: `TracesTableUiReturnType` does not expose `metadata`, and the row mapping only copies explicit fields, so `row.original.metadata.model` was always `undefined`. The live Model value comes from parsing the LiteLLM trace name (`litellm-/`). Remove the dead branch and keep only the name-parsing fallback. Co-Authored-By: Claude Opus 4.7 --- web/src/components/table/use-cases/traces.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/web/src/components/table/use-cases/traces.tsx b/web/src/components/table/use-cases/traces.tsx index 60291f4aadf0..066db8552421 100644 --- a/web/src/components/table/use-cases/traces.tsx +++ b/web/src/components/table/use-cases/traces.tsx @@ -618,15 +618,10 @@ export default function TracesTable({ enableHiding: true, enableSorting: false, cell: ({ row }) => { - const metadata = row.original.metadata as - | { model?: unknown } - | null - | undefined; - const metadataModel = - metadata && typeof metadata.model === "string" - ? metadata.model - : undefined; - if (metadataModel) return metadataModel; + // `TracesTableUiReturnType` does not include metadata, so we can + // only derive the model from fields already in the row. LiteLLM + // sets trace name to `litellm-/` — parse the + // segment after the slash. const name: TracesTableRow["name"] = row.getValue("name"); if (typeof name === "string" && name.startsWith("litellm-")) { const slashIdx = name.indexOf("/");