Skip to content

[HDX-5162] LLM observability dashboard, span chat view, and sessions - #2990

Open
wrn14897 wants to merge 21 commits into
mainfrom
warren/llm-observability
Open

[HDX-5162] LLM observability dashboard, span chat view, and sessions#2990
wrn14897 wants to merge 21 commits into
mainfrom
warren/llm-observability

Conversation

@wrn14897

@wrn14897 wrn14897 commented Aug 25, 2026

Copy link
Copy Markdown
Member

Linear

https://linear.app/clickhouse/issue/HDX-5162/llm-observability-span-chat-view-cost-tracking-sessions-and-llm

Why

Teams running LLM apps and coding agents (OpenAI/Anthropic SDKs, Vercel AI SDK, LangChain via OpenLLMetry/OpenInference, opencode, Claude Code, GitHub Copilot Chat) already send their telemetry to HyperDX — but the product had zero LLM awareness: no chat rendering, no token/cost concepts, no model analytics. This PR adds LLM observability at feature parity with dedicated LLM observability tools while staying HyperDX-native.

Approach: read-time, schema-agnostic

Unlike dedicated LLM observability tools that rely on ingest-time processing and dedicated tables, everything here derives from span/log attribute maps at query time:

  • zero ingestion or schema changes — works retroactively on already-ingested data
  • works with Map and JSON-typed attribute columns via the source's eventAttributesExpression
  • all derived expressions are plain SQL, so they also compose with search, alerts, and custom dashboards

What's included

Normalization lib (packages/app/src/llm/lib) — pure, unit-tested TS:

  • Detects LLM spans and normalizes model, provider, usage (incl. cached + reasoning tokens), cost, session id, TTFT, tool names, agent names, finish reasons, and chat messages (roles, markdown, tool calls)
  • Four dialects: OTel GenAI semconv (attribute- and event-based, including current-registry dotted usage keys like gen_ai.usage.cache_read.input_tokens), OpenLLMetry, OpenInference, Vercel AI SDK — plus real-world variants captured as fixtures from opencode, Claude Code, and GitHub Copilot Chat telemetry (whole-string llm.input_messages, camelCase ai.usage.*, flat input_tokens/cost_usd keys, bracketed model ids like claude-opus-5[1m], copilot_chat.time_to_first_token)

Cost estimation — built but hidden by default (lib/modelPrices.ts, lib/cost.ts, gated on NEXT_PUBLIC_IS_LLM_COST_ENABLED):

  • All cost display (KPI tiles, Est. Cost charts/columns, drawer cost) is off by default pending a price-management story — a bundled catalog goes stale between releases and can't account for provider discounts, so tokens are the primary metric; self-hosters can opt in via the env flag
  • The machinery stays: a price catalog adapted from an MIT-licensed open-source price list (attribution in the source header) covering 10 provider families, an instrumentation-provided cost attribute (gen_ai.usage.cost, llm.cost.total, cost_usd) that always wins over the catalog, and a SQL multiIf generator bound once per query as a WITH alias to stay under ClickHouse's max_query_size. The provided-cost election also dedups token counts, so it runs regardless of the flag

/llm preset dashboard (Overview | Latency | Sessions | Errors), listed on the Dashboards page:

  • Overview: KPI tiles (calls, tokens, est. cost, avg cost/call, cache hit rate, error rate), calls + error trends, token split (uncached/cached input, output, reasoning), cost by model, cache-hit-rate + finish-reason trends (truncation/content-filter signal), models/services/users/error-message tables, p95 by model, TTFT p50/p95, tool analytics (calls by tool, per-tool error rate + p95), and agent attribution (gen_ai.agent.name): per-agent calls, tokens, est. cost, and error rate
  • Latency: a drag-to-select duration heatmap over LLM calls with an attribute delta breakdown (same interaction as the search page's delta mode) — select a slow region to see which attributes (model, service, tool, agent, …) distinguish it from the rest; AI-relevant attributes (model, tokens, cost, tool, agent) are pinned to the top of the breakdown, and include/exclude clicks append to the dashboard's where input
  • Sessions: LLM activity grouped by the cross-dialect session id (gen_ai.conversation.idsession.idai.telemetry.metadata.sessionId) — the correlation surface for instrumentations that stamp session ids but don't propagate trace context. Row click opens a timeline drawer; each call expands into a normalized chat view with role badges, markdown, collapsible tool calls, and a usage/cost summary
  • Errors: side-by-side error rows — LLM trace spans with an error status and correlated error-severity log events — still scoped by the top-bar where/session/time filters (tab=search URLs normalize here)
  • Top-bar scoping: trace source, correlated log source, session filter, where input, time picker — all charts honor them

API attribution helper (packages/api):

  • llmTelemetry() builds Vercel AI SDK experimental_telemetry settings whose metadata the SDK flattens onto every span as ai.telemetry.metadata.* — the keys this dashboard reads for session grouping (sessionId) and user attribution (userId). The chart assistant now stamps teamId/userId; conversation-scoped callers should also pass sessionId so their calls group on the Sessions tab

Correctness & performance notes

  • Token/cost sums are gated on authoritative usage reporters (gen_ai.usage.* / llm.token_count.* / flat primary-reporter keys) so SDK wrapper spans (e.g. Vercel's ai.streamText around doStream) don't double count. Dual-dialect apps (opencode emits an OpenInference span with cost_usd and Vercel spans per call) are deduped by a per-service provided-cost election: a service's provided-cost rows win over its other usage rows, while services without provided costs keep all their usage rows — so cost-reporting apps never evict token-only apps from shared aggregates
  • Cache-hit-rate handles both conventions (OpenAI-style cached-⊆-input vs Anthropic-style exclusive reporting)
  • Session drawer fetches a lightweight scalar list and loads each span's attributes lazily on expand — agent SDKs stamp the full conversation history on every span, so the naive approach shipped ~48 MiB per session vs ~20 KiB now
  • Finish reasons are normalized across encodings (stop vs ["stop"])
  • The Latency tab's delta sampling is row-capped (1000-row stable-hash sample per group, same as search's delta mode) and value-trimmed: agent SDKs stamp full conversation histories on every span, so a raw 1000-row SELECT * sample measured ~412 MiB — attribute values over 256 chars are dropped server-side via mapFilter (~500× smaller), which loses nothing since long values are hidden as high-cardinality anyway

Known limitations

  • Cost display is hidden by default until prices can be fetched or team-managed (the bundled catalog goes stale and ignores provider discounts); flip NEXT_PUBLIC_IS_LLM_COST_ENABLED to opt in
  • An app that stamps a provided cost on only some of its own calls undercounts (the per-service election treats its token-only rows as duplicates) — indistinguishable from duplicate reporting without row-level call identity

Testing

  • 11 unit/component suites, 105 tests in src/llm/__tests__ (per-dialect fixtures lifted from real opencode/Claude Code/Copilot telemetry, cost math and price-catalog matching, SQL expression generation, lazy-loading regression guards)
  • tsc --noEmit clean; eslint 0 errors; knip clean
  • Chart SQL validated against live ClickHouse with real opencode + Claude Code telemetry (Map and JSON schema variants), covering both the provided-cost and price-catalog estimation paths

Screenshots

image image image image image

…s, and /llm dashboard

Adds read-time, schema-agnostic LLM observability on top of existing trace
and log data. No ingestion changes: everything derives from span/log
attribute maps at query time, so it works retroactively on already-ingested
telemetry.

- Normalization lib (packages/app/src/llm/lib): detects LLM spans and
  normalizes model, provider, token usage (incl. cached/reasoning), cost,
  session ids, TTFT, and chat messages across four instrumentation dialects:
  OTel GenAI semconv (attribute- and event-based), OpenLLMetry,
  OpenInference, and the Vercel AI SDK. Includes real-world variants
  observed from opencode and Claude Code telemetry (whole-string
  llm.input_messages, camelCase ai.usage.*, flat token/cost keys,
  bracketed model ids like claude-opus-5[1m]).
- Span side panel: an LLM tab renders the normalized conversation (roles,
  markdown, tool calls) with a usage/cost summary; the Overview tab gains an
  LLM section; trace waterfall labels LLM spans with model + token count.
- Cost estimation: bundled model price catalog (adapted from Langfuse's
  MIT-licensed price list) with regex matching for provider/Bedrock/Vertex
  id flavors; an instrumentation-provided cost attribute always wins.
- /llm preset dashboard (Overview | Sessions | Search):
  - Overview: KPI tiles (calls, tokens, est. cost, avg cost/call, cache hit
    rate, error rate), calls/error trends, token split
    (uncached/cached/output/reasoning), cost by model, finish reasons,
    models/services/users/error tables, latency heatmap, p95 by model,
    TTFT, and tool analytics.
  - Sessions: activity grouped by the cross-dialect session id
    (gen_ai.conversation.id, session.id, ai.telemetry.metadata.sessionId)
    with a drawer timeline; span attributes load lazily per expanded call
    since agent SDKs stamp full conversation history on every span
    (~48 MiB -> ~20 KiB list payload).
  - Search: side-by-side LLM trace span and log event tables, correlating
    signals for instrumentations that emit session ids without trace
    context.
- Aggregations gate token/cost sums on authoritative usage reporters so SDK
  wrapper spans don't double count.

Known limitations: bundled prices go stale between releases, and apps that
double-instrument (e.g. opencode emitting both OpenInference and Vercel AI
spans per call) still double count in sums.
@changeset-bot

changeset-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8741d9a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/app Minor
@hyperdx/api Minor
@hyperdx/otel-collector Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview Aug 28, 2026 7:29pm
hyperdx-storybook Ready Ready Preview Aug 28, 2026 7:29pm

Request Review

@github-actions github-actions Bot added the review/tier-4 Critical — deep review + domain expert sign-off label Aug 25, 2026
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches authentication, tenancy data models, the public API or shipped database config — or substantially changes the query rendering engine, background tasks, the OTel pipeline, image build, or release CI.

Why this tier:

  • Large diff: 5624 production lines changed (threshold: 1000)
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api)
  • Touches API routes or data models — hidden complexity risk

Review process: Deep review from a domain expert. Synchronous walkthrough may be required.
SLA: Schedule synchronous review within 2 business days.

Stats
  • Production files changed: 48
  • Production lines changed: 5624 (+ 1891 in test files, excluded from tier calculation)
  • Branch: warren/llm-observability
  • Author: wrn14897

To override this classification, remove the review/tier-4 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a schema-agnostic LLM observability dashboard, normalized multi-dialect telemetry extraction, session chat views, latency analysis, and optional cost estimation. It also adds Vercel AI SDK telemetry metadata for team and user attribution.

  • Adds overview, latency, sessions, and errors views under /llm.
  • Normalizes LLM models, usage, costs, messages, tools, agents, and session identifiers at query time.
  • Adds lazy per-span session detail queries and reusable delta-chart prioritization and sampling controls.
  • Adds API telemetry metadata helpers and broad unit/component coverage.

Confidence Score: 4/5

The PR is not yet safe to merge because session rows without trace identity can still resolve to the wrong span detail when identifiers collide.

The trace-aware lookup fixes collisions for populated trace IDs, but the session list still converts absent trace IDs to an empty string and the detail query applies LIMIT 1 to a trace ID, span ID, and timestamp tuple that can remain non-unique.

Files Needing Attention: packages/app/src/llm/dashboard/LLMSessionPanel.tsx and packages/app/src/llm/dashboard/SessionSpanDetail.tsx

Important Files Changed

Filename Overview
packages/app/src/llm/dashboard/LLMDashboardPage.tsx Coordinates source selection, URL-backed filters, time range, and the four LLM dashboard tabs.
packages/app/src/llm/dashboard/LLMSessionPanel.tsx Loads lightweight session span rows and passes their trace, span, and timestamp fields to lazy detail views.
packages/app/src/llm/dashboard/SessionSpanDetail.tsx Fetches expanded span attributes using trace, span, and timestamp predicates, but the lookup remains non-unique when trace identity is absent.
packages/app/src/llm/lib/expressions.ts Builds cross-dialect ClickHouse expressions for normalized LLM fields, usage election, and cost calculations.
packages/app/src/llm/lib/extract.ts Extracts normalized LLM span fields and messages from supported telemetry dialects.
packages/api/src/controllers/ai.ts Adds a helper that enables Vercel AI SDK telemetry and removes nullish metadata values.
packages/app/src/components/DBDeltaChart.tsx Adds caller-defined selected expressions and priority-property ordering to shared delta analysis.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  SDK[LLM SDK telemetry] --> CH[(ClickHouse attributes)]
  CH --> Normalize[Read-time dialect normalization]
  Normalize --> Overview[Overview and attribution]
  Normalize --> Latency[Latency and delta analysis]
  Normalize --> Sessions[Session timeline]
  Sessions --> Detail[Lazy span detail and chat view]
  Normalize --> Errors[Trace and log errors]
Loading

Reviews (18): Last reviewed commit: "fix(app): cap key-path indices when reco..." | Re-trigger Greptile

Comment thread packages/app/src/llm/dashboard/SessionSpanDetail.tsx Outdated
@@ -0,0 +1,316 @@
import { useCallback, useEffect, useState } from 'react';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Components exceed file-size limit

This new component is 316 lines, while LLMSessionPanel.tsx is also 302 lines. Both exceed the repository's 300-line maximum, increasing maintenance cost; split them into smaller focused components.

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

Comment thread packages/app/src/llm/dashboard/AgentToolCharts.tsx
Comment on lines +77 to +81
!isLoading && (
<Text size="sm" c="dimmed">
No LLM messages found on this span. Prompt and completion capture
may be disabled in the instrumentation.
</Text>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Ad-hoc empty states added

This no-message branch and the no-session-results branch in LLMSessionPanel.tsx render plain Text elements instead of the required shared EmptyState, bypassing the repository's consistent empty-state presentation and behavior. Use @/components/EmptyState for both branches.

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 324 passed • 1 skipped • 1396s

Status Count
✅ Passed 324
❌ Failed 0
⚠️ Flaky 1
⏭️ Skipped 1

Tests ran across 4 shards in parallel.

View full report →

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Deep Review

Scope: PR #2990 — LLM observability dashboard, span chat view, and sessions (~5,600 production lines, TypeScript/React + query-time ClickHouse SQL generation, base aea7398). Reviewed against aea7398 in report-only mode.

Intent: Add read-time, schema-agnostic LLM observability — a /llm preset dashboard, span chat rendering, multi-dialect telemetry normalization, optional (default-off) cost estimation, and API telemetry attribution — deriving everything from span/log attribute maps at query time.

Security review found no injection, authz, or secret-leak issues: user-controlled inputs (sessionId, row.traceId/spanId/ts, delta filter values) are parameterized via SqlString, and whereLanguage: 'sql' follows the existing trusted-by-design model. No P0/P1 issues surfaced.

✅ No critical issues found.

🟡 P2 — recommended

  • packages/app/src/llm/dashboard/SessionSpanDetail.tsx:60useQueriedChartConfig errors are ignored, so a failed heavy attribute fetch falls through to the "No captured messages on this span" copy, telling the user instrumentation is misconfigured rather than that the query failed; LLMSessionPanel.tsx:169/:206 similarly render a failed spans query as "No LLM spans found" and blank totals.
    • Fix: Destructure isError/error from the query hooks and render a distinct error/retry state instead of the empty-data copy.
    • reliability
  • packages/app/src/llm/dashboard/LLMSessionPanel.tsx:119expandedItems holds positional keys (span-${index}) and is never reset when sessionId changes, so switching sessions auto-expands the index-matched row in the new session and eagerly fires the megabyte-scale per-span attribute lookup the lazy design exists to avoid, showing a span the user never clicked.
    • Fix: Clear expansion on session change (e.g. reset expandedItems when sessionId changes, or key the Accordion by sessionId).
    • frontend-races
  • packages/app/src/llm/lib/extract.ts:35 — The ~11 per-dialect attribute-key arrays are duplicated between the JS path (extract.ts) and the SQL path (expressions.ts) and have already drifted (TTFT_MS_KEYS ordering differs), so key precedence diverges and the span subpanel can report a different value than the dashboard aggregate for the same span.
    • Fix: Hoist the shared key catalogs into one module both paths import, mirroring the single-source pattern already used in detect.ts.
    • maintainability, kieran-typescript
  • packages/app/src/llm/dashboard/OverviewCharts.tsx:204 — The Error Rate time-series computes error_count / total_count without the greatest(total_count, 1) guard the KPI tile uses, producing NaN points in any time bucket with zero calls (common on sparse LLM traffic).
    • Fix: Divide by greatest(total_count, 1) to match the guarded KPI tile.
    • correctness, testing
  • packages/app/src/llm/dashboard/SessionSelect.tsx:46 — The distinct-session query is capped at limit 10000 and fed directly into a searchable, non-virtualized Mantine Select, so a wide window with thousands of sessions mounts that many option nodes and re-filters them on each keystroke, stalling the main thread.
    • Fix: Lower the display bound to a few hundred most-recent sessions and/or use a virtualized or server-side async combobox.
    • performance
  • packages/app/src/llm/dashboard/SessionSpanDetail.tsx:41 — When telemetry carries no trace context, row.traceId/row.spanId coerce to '' and the LIMIT 1 detail lookup relies solely on nanosecond-timestamp uniqueness, so concurrent context-less spans sharing a timestamp can resolve to another row's conversation; the earlier cross-trace span-id fix does not cover the empty-id case.
    • Fix: Fall back to a distinguishing identity (or surface an explicit "cannot resolve span" state) when trace and span ids are both empty rather than displaying an arbitrary matching row.
    • previous-comments
🔵 P3 nitpicks (9)
  • packages/app/src/llm/dashboard/LLMDashboardPage.tsx:1LLMDashboardPage.tsx (375 lines), LLMSessionPanel.tsx (326 lines), and expressions.ts (473 lines) exceed the documented 300-line limit (code_style.md, CLAUDE.md); the components grew past this after a prior review raised it.
    • Fix: Extract cohesive sub-components/modules (toolbar, beta header, SessionSpanItem, cost vs token expressions) to bring each file under 300 lines.
    • project-standards, maintainability, previous-comments
  • packages/app/src/llm/dashboard/OverviewCharts.tsx:71 — New chart titles use Title Case (e.g. "Total Tokens", "Error Rate", "Cache Hit Rate") instead of the documented sentence-case convention, across OverviewCharts, TokenCostCharts, AgentToolCharts, AttributionCharts, LatencyCharts, EfficiencyCharts, and SessionsTab.
    • Fix: Convert new chart/section titles to sentence case, preserving acronyms like LLM and P95.
    • project-standards
  • packages/app/src/llm/dashboard/LLMSessionPanel.tsx:317 — The no-session-results branch and the no-captured-messages branch (SessionSpanDetail.tsx:89) render plain Text instead of the shared @/components/EmptyState the standards mark as required.
    • Fix: Use the shared empty-state component, or confirm inline text is an intentional exception for the accordion hint.
    • project-standards, previous-comments
  • packages/app/src/llm/lib/cost.ts:78resolveSpanCostUsd gates on providedCostUsd !== undefined, so a span reporting a cost of exactly 0 is treated as authoritative, whereas the SQL twin gates on > 0 and estimates instead — a JS/SQL divergence for zero-cost rows.
    • Fix: Align the JS gate with the SQL > 0 behavior (or document the intended handling) and add a zero-cost test case.
    • testing
  • packages/app/src/llm/lib/extract.ts:262formatCostUsd strips a value whose toFixed(6) rounds to '0.000000' down to "$0." for tiny nonzero costs below ~5e-7.
    • Fix: Fall back to a <$0.000001-style label for sub-microdollar costs.
    • correctness
  • packages/app/src/llm/lib/expressions.ts:274 — SQL totalTokens derives only from input+output keys, but hasReportedTokens also gates on llm.token_count.total, so a span reporting only a total-token key counts as a call contributing 0 tokens and undercounts.
    • Fix: Fold standalone total-token keys into the totalTokens/effectiveInputTokens derivation.
    • correctness
  • packages/app/src/llm/lib/detect.ts:97 — The SQL predicate treats any non-empty openinference.span.kind as an LLM span, while the client isLLMSpan restricts to a known kind set, so query-time scope can diverge from client detection.
    • Fix: Restrict the SQL branch to the same OpenInference LLM-kind set via an IN (...).
    • correctness
  • packages/app/src/llm/dashboard/SessionSelect.tsx:59 — Result parsing uses (d: any) => d.session and an untyped mixed values array (bare strings plus a {value,label} item), disabling type checking on the code that shapes the dropdown.
    • Fix: Type the row as Record<string, unknown>, coerce with the existing asString helper, and map sessions to {value,label} items.
    • kieran-typescript
  • packages/app/src/llm/dashboard/AttributionCharts.tsx:102 — The cost-gated "Est. Cost" column block and the 4-mantissa currency NumberFormat are copy-pasted across four chart files (AgentToolCharts.tsx:204, SessionsTab.tsx:133, TokenCostCharts.tsx:162/172).
    • Fix: Extract a shared costColumn(expressions) helper and move the currency format next to COST_USD_NUMBER_FORMAT.
    • maintainability

Pre-existing (not counted toward verdict)

  • packages/app/src/components/Search/DBSearchHeatmapChart.tsx:94 — The date-range-change guard uses an in-component ref that reinitializes to null on remount; because the Latency tab renders under Tabs keepMounted={false}, changing the time range while the tab is unmounted leaves a stale heatmap selection restored against the new window. Pre-existing mechanism newly exposed by the tab usage; consider persisting the last-seen range key outside the component.

Reviewers (11): correctness, security, reliability, performance, kieran-typescript, frontend-races, testing, maintainability, project-standards, previous-comments, api-contract. (The adversarial reviewer timed out; its focus areas — double-counting election, session-id collisions, cache-hit-rate, and cost/error-rate division — were verified directly during synthesis.)

Testing gaps:

  • No test asserts the session spans query selects only scalar columns (the core of the 48 MiB→20 KiB lazy-load claim) or covers the query-error path for the session drawer.
  • No test covers expanding a row then switching sessions to confirm the new session opens collapsed with no eager per-span fetch.
  • SQL arithmetic (effectiveInputTokens inclusive/exclusive branch, finishReason bracket-strip regex, error-rate/cache-hit-rate division) is asserted only structurally, never executed against a real engine.
  • No test for resolveSpanCostUsd at providedCostUsd: 0, sub-microdollar formatCostUsd, or total-only token reporters.

@wrn14897 wrn14897 changed the title feat(app): LLM observability dashboard, span chat view, and sessions [HDX-5162] LLM observability dashboard, span chat view, and sessions Aug 25, 2026
…ion lookup hardening

Post-review fixes for the LLM observability branch, driven by a live
comparison against opencode's self-reported session cost: the /llm
dashboard showed ~$22.72 for a session opencode itself priced at $9.16.

Cost accuracy (verified exact against opencode's cost_usd on 111 calls):

- Provided-cost election: apps that stamp their own per-call cost
  (cost_usd / llm.cost.total / gen_ai.usage.cost) are treated as the
  authoritative reporters, and all token/cost/call aggregations sum only
  those rows when any exist in scope (llmGatedSumExpr /
  llmGatedCountExpr, rendered as raw select aggregates). This dedupes
  dual-instrumented apps — opencode emits OpenInference spans (with
  cost) AND Vercel AI SDK spans (with gen_ai.usage.*) for every call, in
  separate traces, so no row-local gate could catch it.
- Cache-aware estimation: the SQL cost expression now prices uncached
  input, cache reads (discounted), cache writes (Anthropic's 1.25x
  premium, new catalog rate + attribute keys incl. OpenInference
  prompt_details.cache_write, Vercel inputTokenDetails.cacheWriteTokens,
  and flat cache_creation_tokens), and output separately, matching the
  TS-side computeCostUsd. The inclusive/exclusive input-token heuristic
  now accounts for writes, and totalTokens reports effective context.
- Query-size guard: the enriched cost expression embeds the price
  catalog per token term; the first live run exceeded ClickHouse's
  256 KiB max_query_size. Rates are now factored into per-term multiIfs
  and the whole expression is bound once per query as a WITH expression
  alias (LLM_COST_SQL_ALIAS), keeping dashboard queries at ~80 KiB.

Session drawer correctness (review feedback):

- The per-span attribute lookup now pins TraceId alongside SpanId +
  timestamp (span ids can be empty or collide across traces) and is
  bounded to the searched window for partition pruning.

Also trims the llm module's public surface to what external consumers
import (fixes 23 knip unused-export findings) and replaces the unused
zod chat-message schemas with plain interfaces. Adds the missing
changeset for the LLM observability feature.
The LLM observability branch added 6 eslint-disable comments, tripping the
app/eslint-disable ratchet (150 > baseline 144). Remove the escapes by
fixing the underlying patterns instead of suppressing them:

- Chat messages get a stable `id` assigned in extractConversation
  (conversations are immutable once extracted), so message lists key on
  data instead of array indexes.
- Session timeline rows carry their accordion `itemValue` in the row data
  built per fetch, replacing the index-derived key/value pair.
- The session filter now lives in the URL only: SessionSelectControlled
  becomes a plain value/onChange SessionSelect wired straight to the
  nuqs param, deleting the two deliberately-under-depped form<->URL sync
  effects (the drawer's "Filter dashboard" action writes the same param).
- The trace-source default adoption effect gets full dependencies — the
  select only offers usable trace sources, so a user selection always
  resolves to itself and the effect can never fight it.
Adds the standard beta badge (matching Service Map's nav badge) next to
the LLM breadcrumb, plus an info hover card explaining what to expect:
the dashboard is experimental, which instrumentations it understands,
that costs are catalog estimates unless the instrumentation reports its
own cost, and how dual-instrumented apps are counted.
Hide the passive LLM surfaces on event/trace views while the feature
bakes, mirroring the alert-details flag pattern: default off, enabled in
dev via .env.development, opt-in comment in docker-compose.yml.

Gated under IS_LLM_PANELS_ENABLED:
- the LLM tab in the event row side panel
- the LLM section on the row Overview tab
- the LLM tab in the trace span detail panel
- the model + token-count suffix on waterfall span labels

The /llm dashboard and its dashboards-list entry are intentionally not
gated — the dashboard is opt-in by navigation and already labeled beta.
Comment thread packages/app/src/llm/dashboard/SessionSpanDetail.tsx
Drop all passive LLM surfaces on existing event/trace views so this PR
ships only the opt-in dashboard; the side-panel integrations can return
in a follow-up:

- Restore DBRowSidePanel(+types), DBRowOverviewPanel, DBTracePanel, and
  DBTraceWaterfallChart to main (removes the LLM tab, overview section,
  span-detail tab, and waterfall label suffix).
- Remove the now-moot IS_LLM_PANELS_ENABLED flag and its env/compose
  wiring.
- Delete the pieces those surfaces orphaned: LLMConversationPanel,
  lib/rowData (row-data extraction glue), asLLMEvents, their tests, and
  the @/llm barrels (dashboard code imports defining modules directly).
- Reword the changeset to describe the dashboard only.

Outside src/llm the branch now only touches AppNav (dashboards-group
active state for /llm) and the dashboards-list preset entry.
- Recognize current-registry dotted semconv usage keys
  (gen_ai.usage.cache_read.input_tokens,
  gen_ai.usage.reasoning.output_tokens) emitted by GitHub Copilot Chat
  and newer SDKs, plus copilot_chat.time_to_first_token (ms) for TTFT.
- Expand the model price catalog with xAI, DeepSeek, Mistral, Cohere,
  Qwen, Meta Llama, and Amazon Nova families across bare, OpenRouter,
  HuggingFace, and Bedrock id flavors.
- Add agent attribution (gen_ai.agent.name / agent.name): per-agent
  calls, tokens, est. cost, and error rate charts on the dashboard and
  an agent badge in the span subpanel.
…pling

The delta breakdown's sampling queries are row-capped (LIMIT 1000 +
stable hash order + PartIds indexHint, inherited from search's delta
mode), but they SELECT * — and agent SDKs stamp full conversation
histories on every span, so a 1000-row LLM-span sample measured ~412
MiB of SpanAttributes (~800 MiB per selection across the outlier +
inlier queries).

DBDeltaChart gains an opt-in selectExpression (default '*', threaded
through DBSearchHeatmapChart as deltaSelectExpression). The LLM Latency
tab passes a trimmed select that drops attribute values longer than 256
chars server-side via mapFilter (~500x smaller: 0.11 MiB vs 55 MiB
measured on the same sample) — long values are exactly what the
breakdown hides as high-cardinality anyway. Falls back to SELECT * for
JSON-typed or derived attribute columns, and holds queries until the
JSON-column lookup resolves. Search page behavior unchanged.

@brandon-pereira brandon-pereira left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall works well!

You can use the AI Assistant on chart explorer to test this. As we rollout AI in HyperDX users can use this dashboard to observe hyperdx :)

Comment thread packages/app/src/components/Dashboards/DashboardsListPage.tsx
Comment thread packages/app/src/llm/lib/modelPrices.ts
Row hrefs were computed from a window.location snapshot at render time.
Cached table rows can render before an async nuqs URL write (e.g. a tab
switch) lands, baking the previous tab into the link — clicking a
session then navigated back to the old tab underneath the drawer.
Build hrefs from useSearchParams() so they recompute when the URL
changes, and extract a pure helper with regression tests.
Scope both row tiles to failures: trace spans with an error status and
correlated error-severity log events. The top-bar where/session/time
scoping still applies, so the tab remains a filterable row browser.
tab=search URLs stay accepted and normalize to the Errors tab. Adds a
log-side isError expression mirroring the serviceDashboard convention.
Add llmTelemetry(), which builds Vercel AI SDK experimental_telemetry
settings from metadata the SDK flattens onto every span as
ai.telemetry.metadata.* — the keys the /llm dashboard reads for session
grouping (sessionId) and user attribution (userId). Wire teamId/userId
into the chart assistant call.

EE note: the notebook investigation agent should adopt this at its
generateText call site with sessionId set to the notebook id so its
calls group on the LLM dashboard Sessions tab:
  experimental_telemetry: llmTelemetry({ sessionId: notebookId, teamId, userId })
Double backslashes before escaping quotes when embedding catalog regex
patterns in string literals: ClickHouse decodes recognized escapes
(\b, \t, \xHH) inside literals, so a future pattern using e.g. a word
boundary would silently corrupt. Write the finish-reason char class
backslash-free ('[]["]'). Adds a round-trip regression test that
simulates ClickHouse literal decoding and re-matches catalog patterns.
Cost display (KPI tiles, Est. Cost columns/charts, drawer and subpanel
cost) is now gated on IS_LLM_COST_ENABLED
(NEXT_PUBLIC_IS_LLM_COST_ENABLED, default off): the bundled price
catalog goes stale between releases and can't account for provider
discounts, so tokens are the primary metric until there's a
price-management story. The catalog, cost expressions, and
provided-cost election stay — the election also dedups token counts,
and flipping the flag restores the full cost UX. With the flag off the
~70 KiB cost WITH-binding is skipped on every dashboard query.
The election gated whole aggregation scopes: one provided-cost span
anywhere in a KPI tile, time bucket, or mixed group made the gated
sums/counts keep only provided-cost rows, silently dropping every
token-only app sharing the trace source. Elect per service instead —
dual-dialect emission (opencode's OpenInference+cost span plus Vercel
spans per call) is an app-level property and both dialects share a
ServiceName — via sumMap-keyed per-service branches summed with
arraySum.

Validated against live ClickHouse: mixed two-service scope now returns
all calls/tokens (was dropping the token-only app), and single-service
totals are unchanged (gated cost still exactly matches opencode's
self-reported cost_usd sum, 250/250 calls).
The drawer body rendered blank while the span-list query was in flight
(the empty state was gated on !isLoading but nothing rendered in its
place); show a centered loader instead. Header token/cost totals now
render only once the totals query returns rather than flashing
"0 tokens".
keyPathsToArray accepted any non-negative integer index, so an ingested
attribute key like llm.input_messages.2000000000.message.content
created a ~2e9-length sparse array — the trailing filter() iterates the
whole range (~27s measured in V8), freezing the tab when the span is
expanded, and nested arrays hit the same via downstream map(). Reject
keys carrying any integer segment above 4096; real conversations top
out at a few hundred entries.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automerge review/tier-4 Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants