Skip to content

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

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

[HDX-5162] LLM observability dashboard, span chat view, and sessions#2990
wrn14897 wants to merge 11 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 (lib/modelPrices.ts, lib/cost.ts):

  • Bundled price catalog adapted from an MIT-licensed open-source price list (attribution in the source header), covering OpenAI, Anthropic, Google, xAI, DeepSeek, Mistral, Cohere, Qwen, Meta Llama, and Amazon Nova families across bare, OpenRouter, HuggingFace, Bedrock, and Vertex id flavors
  • An instrumentation-provided cost attribute (gen_ai.usage.cost, llm.cost.total, cost_usd) always wins; catalog is the fallback
  • SQL multiIf generator for dashboard aggregation, bound once per query as a WITH alias to stay well under ClickHouse's max_query_size

/llm preset dashboard (Overview | Latency | Sessions | Search), 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
  • Search: side-by-side LLM trace-span and log-event tables
  • Top-bar scoping: trace source, correlated log source, session filter, where input, time picker — all charts honor them

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
  • 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

  • Bundled prices go stale between releases (provided cost attributes always win); team-editable overrides are a natural follow-up
  • Apps that double-instrument every call (e.g. opencode emitting both OpenInference spans and Vercel AI spans) still double count in aggregates

Testing

  • 12 unit/component suites, 111 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: 8eae348

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 26, 2026 12:16am
hyperdx-storybook Ready Ready Preview Aug 26, 2026 12:16am

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: 5423 production lines changed (threshold: 1000)

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: 45
  • Production lines changed: 5423 (+ 1693 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

The PR adds a schema-agnostic LLM observability dashboard that derives normalized model, usage, cost, latency, tool, agent, and conversation data from telemetry at query time.

  • Adds overview, latency, sessions, and search surfaces under /llm.
  • Introduces cross-dialect LLM normalization, cost estimation, and ClickHouse expression generation.
  • Adds lazy session-span detail loading and reusable delta-chart prioritization and sampling controls.

Confidence Score: 4/5

The PR does not appear safe to merge until session entries without trace context can be looked up without selecting an unrelated colliding span.

Session rows normalize absent trace IDs to an empty string, and the lazy detail query uses that value with span ID and timestamp under an unordered LIMIT 1, leaving the previously reported wrong-conversation lookup reachable.

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

Important Files Changed

Filename Overview
packages/app/src/llm/dashboard/SessionSpanDetail.tsx Adds lazy attribute loading for a selected session span, but rows lacking trace context remain ambiguously identified.
packages/app/src/llm/dashboard/LLMSessionPanel.tsx Queries session summaries and lazily expands individual spans while forwarding normalized trace, span, and timestamp identity.
packages/app/src/llm/hooks/useLLMDashboardExpressions.ts Builds source-aware normalized SQL expressions consumed throughout the LLM dashboard.
packages/app/src/llm/lib/expressions.ts Defines cross-dialect ClickHouse expressions for LLM detection, usage, cost, sessions, and attribution.
packages/app/src/components/DBDeltaChart.tsx Adds configurable sampling projections and stable priority-property partitioning for domain-specific delta views.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Telemetry[Span and log attributes] --> Normalize[Cross-dialect LLM expressions]
  Normalize --> Queries[ClickHouse read-time queries]
  Queries --> Overview[Overview charts]
  Queries --> Latency[Latency and delta analysis]
  Queries --> Sessions[Session timeline]
  Sessions --> Detail[Lazy span attribute lookup]
  Detail --> Chat[Normalized chat view]
  Queries --> Search[Span and log search]
Loading

Reviews (11): Last reviewed commit: "perf(app): trim oversized attribute valu..." | 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 • 307 passed • 1 skipped • 1241s

Status Count
✅ Passed 307
❌ Failed 0
⚠️ Flaky 2
⏭️ 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

Read-time, schema-agnostic LLM observability feature (~7.1k added lines, 58 files) under packages/app/src/llm/. Reviewed against base 9155b436. SQL construction and markdown rendering paths were traced for injection/XSS and came back clean; findings below are reliability and standards issues, none of them ship-blockers.

✅ No critical issues found.

🟡 P2 — recommended

  • packages/app/src/llm/dashboard/LLMSessionPanel.tsx:165 — The session spans/totals queries and the per-span detail fetch (SessionSpanDetail.tsx:60) destructure only data/isLoading, so a failed or timed-out ClickHouse query renders the "No LLM spans found for this session" / "No captured messages on this span" empty state, masking the error as absence of data.
    • Fix: Destructure isError from each useQueriedChartConfig call and render a distinct error/retry affordance instead of the empty-state text on failure.
    • reliability
  • packages/app/src/llm/dashboard/SessionSpanDetail.tsx:41 — The lazy attribute lookup now pins traceId AND spanId AND timestamp, but the Sessions feature explicitly targets telemetry without propagated trace context; when traceId is empty the LIMIT 1 predicate can resolve to a different row if spanId and timestamp also collide, showing another span's conversation.
    • Fix: Add a disambiguating identity column (or assert single-row match) so an empty-traceId lookup cannot silently resolve to a colliding row.
    • previous-comments
🔵 P3 nitpicks (3)
  • packages/app/src/llm/dashboard/LLMDashboardPage.tsx:1LLMDashboardPage.tsx (372 lines) and LLMSessionPanel.tsx (310 lines) exceed the 300-line component maximum documented in AGENTS.md.
    • Fix: Extract cohesive sub-components until each file is under the documented limit.
  • packages/app/src/llm/dashboard/AgentToolCharts.tsx:84 — New chart titles (Tool Calls by Tool, Token Usage, LLM Calls by Model, etc.) use title case, against the repo's sentence-case copy convention.
    • Fix: Convert new dashboard chart titles to sentence case.
  • packages/app/src/llm/dashboard/SessionSpanDetail.tsx:89 — The no-message branch here and the no-session-results branch in LLMSessionPanel.tsx:299 render plain Text instead of the shared @/components/EmptyState component.
    • Fix: Replace the ad-hoc Text empty states with the shared EmptyState component.

Reviewers (3 completed of 13 dispatched): security, reliability, previous-comments — corroborated by direct verification of the diff (file sizes, chart titles, EmptyState usage, and the SessionSpanDetail predicate). The correctness, adversarial, performance, testing, maintainability, project-standards, kieran-typescript, frontend-races, agent-native, and learnings reviewers were dispatched but had not returned when synthesis was finalized; their depth (cost/token math, delta-sampling, dialect normalization edge cases) is not reflected here.

Testing gaps:

  • No test covers the query-error path for LLMSessionPanel / SessionSpanDetail (misleading empty-state-on-error is unguarded).
  • No test exercises SessionSpanDetail resolution when traceId is empty with colliding spanId/timestamp.
  • No test asserts buildDeltaFilterClause safely handles an attribute key with SQL metacharacters on a JSON-typed attribute column (the raw-key fallback path — low risk, runs read-only under the viewer's own credentials).
  • No test asserts ChatMessageItem rejects raw HTML / javascript: links (relies on react-markdown v10 defaults).

@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.
Keeps the MIT attribution header in modelPrices.ts intact.
Moves the latency heatmap out of Overview into a dedicated Latency tab
that works like the search page's delta mode: drag a region on the
duration heatmap to compare the selected spans against the rest and see
which attributes differ. Include/exclude clicks on a breakdown value
append the condition to the dashboard's where input, scoping every tab.

Reuses DBSearchHeatmapChart (URL-persisted selection, settings drawer,
delta chart) with the LLM span scope and no cost WITH alias — the
sampling queries never reference cost. Overview keeps the p95-by-model
and TTFT trend charts.
…kdown

DBDeltaChart gains an opt-in isPriorityProperty predicate (threaded
through DBSearchHeatmapChart) that pins matching visible properties
above the rest while preserving score order within each group. The LLM
Latency tab passes isLLMAttributeKey, built from the dashboard's
attribute key registries plus the gen_ai/llm/ai/openinference/
copilot_chat namespaces, so model, token, cost, tool, and agent
attributes always lead the breakdown. Search page ranking unchanged.
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant