Skip to content

fix(security): rebase the gascity SSO patch onto @hyperdx/app@2.42.0; stop the proxy secret leaking to logs and the ClickHouse proxy - #4

Merged
julianknutsen merged 729 commits into
gascity-ssofrom
gascity/rebase-2.42.0
Oct 25, 2026
Merged

julianknutsen merged 729 commits into
gascity-ssofrom
gascity/rebase-2.42.0

Conversation

@julianknutsen

@julianknutsen julianknutsen commented Oct 24, 2026 •

Copy link
Copy Markdown

Rebases the gascity reverse-proxy (forward-auth) SSO patch from @hyperdx/app@2.28.0 onto @hyperdx/app@2.42.0 (cf00ae53, released 2026-10-23). It folds in #2 (rotating the secret with an overlap window, plus CI hardening) and adds two security fixes.

Merge with "Create a merge commit". Do not squash or rebase-merge. This branch is the 15-commit stack on 2.42.0 plus a merge -s ours of the current gascity-sso (7b24ad4f). The tree equals the stack, and the old tip stays reachable as the second parent. That keeps the deployed 2.28.0-gascity-7b24ad4fa2f9 image traceable to its source without a force-push or any branch-protection change.

Security findings

No secret values appear in this PR, its commits or its test fixtures. The smoke harness uses fake, test-only values.

ID Severity Finding Where it is fixed
F1 High The gate injects the proxy shared secret on every request. Stock HyperDX's pino-http request log serializes req.headers verbatim, so the secret ended up in every API request log line, along with session and gate cookies. Stock 2.42 still logs this header: upstream redacts only authorization, cookie, set-cookie and location. Those logs are shipped to the log store, where HyperDX users can read them, and were also exported over OTLP. Here, 97dd676f. consumeProxyAuthSecret is mounted first in api-app.ts, ahead of pino-http. It moves the presented secret into a module-private WeakMap and deletes it from req.headers and rawHeaders. Rotating the secret after deploy is still required.
F2 Medium The ClickHouse proxy (httpxy, outgoing.headers = { ...req.headers }) forwarded the secret, the gate cookie and any bearer token to the connection host, and any HyperDX user can point that host anywhere. Here: 97dd676f removes the secret, and 53376697 strips cookie and authorization from the proxied request. ClickHouse authenticates with the X-ClickHouse-* headers the proxy sets. The strip can go upstream as-is.
F1b Medium (latent) The bundled OpenTelemetry SDK's HTTP instrumentation runs at the Node http server, before any Express middleware. With HDX_NODE_ADVANCED_NETWORK_CAPTURE on, it records every request header on the server span, so the secret, cookies and bearer tokens would be exported in traces (reproduced with the smoke harness). Opt-in; our deployment does not set it. Here, 3f2370eb: the API refuses to start with proxy auth and advanced network capture both on.
F3 Medium (deployment config) The patch's documented requirement that only the gate can reach the app port is not fully enforced by our network configuration, so the shared secret is the only control on that path. Combined with F1, that is an impersonation path. Private infrastructure repo, not this one.
F4 Medium (deployment config) Our deployment set HYPERDX_API_KEY without an OTLP endpoint. The bundled SDK therefore exported the app's own logs, traces and metrics (including F1's secret) to HyperDX's hosted ingest, and /api/config returned the key to unauthenticated callers. Deployment repo: the repin PR drops the key. The SDK does not initialize without it.

The trust logic itself checked out correct:

  • It compares secrets with timingSafeEqual after a length check.
  • It fails closed when no secret is configured.
  • It refuses bare sessions and blocks email domains outside the allowlist.
  • It rejects multi-valued and control-character identity headers.

Commits (git range-diff @hyperdx/app@2.28.0..7b24ad4f @hyperdx/app@2.42.0..2ae4cede^1)

Commit
1 8ad46914 feat(auth): reverse-proxy (forward-auth) SSO Changed. R1: hook reshaped for 2.42's auth.ts
2–4 4139c2a7, 47412501, 28efccfc ci / lint / jest fixes Unchanged (=)
5 f567cb14 feat(auth): support overlapping proxy secret rotation From #2 (87dde18a), conflict-resolved (R4)
6 a38ca3bc ci: add safe Gas City PR quality gate From #2 (f09be641), conflict-resolved (R5, R6)
7 97dd676f fix(auth): take the proxy-auth secret off the request before it can be logged or forwarded New (F1, F2)
8 c5b2f4a2 ci(gascity): adapt image + PR workflows to the 2.42 base New (R6, R7)
9 53376697 fix(api): do not forward browser credentials to ClickHouse connection hosts New (F2)
10 07aa2451 ci(gascity): drop the inherited upstream workflows from the build branch New
11 7fdaee3b docs(gascity): refresh GASCITY-FORK.md for the 2.42 base; add the smoke harness New
12 a7c6d7cf fix(auth): validate the gate email header in linear time New. See the CodeQL note below
13 3f2370eb fix(auth): refuse to start proxy auth with the SDK's advanced network capture New (review). F1b
14 c3b763d7 ci(gascity): least-privilege image workflow; guard the capture check and the proxy strip New (review)
15 8855373e test(gascity): smoke attack cases for credentials, error paths, telemetry and rotation New (review)
— 2ae4cede merge -s ours of gascity-sso Tree equals commit 15

The PR diff shows the whole upstream release (1,798 files). To review only the fork delta (32 files, +2,038/−5,253; the deletions are the 18 inherited workflows), run:

git diff @hyperdx/app@2.42.0 2ae4cede^1
git diff @hyperdx/app@2.42.0 2ae4cede^1 -- . ':!.github/workflows'   # code only: 12 files, +1,803

Upstream-owned files changed: api-app.ts +4, middleware/auth.ts +15, config.ts +72 (one additive block), routers/api/root.ts +7, routers/api/clickhouseProxy.ts +6. Everything else is fork-owned.

The rebase broke silently in three places, all fixed

The 4 original commits applied with no textual conflicts, but the result was still broken:

  • R1 middleware/auth.ts: upstream removed setTraceAttributes and moved the success path to setBusinessContext + nextWithQueryAttribution. The old hook no longer type-checked and skipped query attribution. The hook is now 4 lines: in proxy mode, unless the session already belongs to the gate-vouched identity, establishProxyAuthSession 401s or logs the user in and re-enters isUserAuthenticated, ending on upstream's own success path. It cannot loop. The decision table is unchanged; the new proxyAuthGuard.test.ts (12 cases) proves it, and disabling the hook fails 8 of 12.
  • R3 lint budget: 2.42 adds no-unsafe-type-assertion with --max-warnings 302, and the patch's tests pushed the count to 309. The two fork test files now use file-level disables, and the fork's production code adds 0 warnings. Total: 301, equal to upstream's baseline.
  • R6 symbol guard: the 2.42 API build is flat (build/*.js, not build/src/*.js), so the old guard paths would have failed or passed vacuously. The guard now checks 8 exact file:symbol pairs, including consumeProxyAuthSecret and establishProxyAuthSession, and fails closed if a path moves.

Also: #2's tests moved to the fork-owned __tests__/proxyAuthConfig.test.ts, so upstream's config.test.ts is byte-identical again (R4). All inherited upstream workflows are deleted, and both fork workflows fail if a rebase re-adds one (R5). The image tag and CODE_VERSION now derive from packages/app/package.json, so this build is tagged 2.42.0-gascity-<sha12> (R7).

Verification (on 2ae4cede, Node 22.23)

  • yarn install --immutable, then common-utils dev:build: OK
  • yarn workspace @hyperdx/api run lint: 0 errors, 301 warnings (budget 302; none on fork lines)
  • tsc --noEmit: clean
  • Fork Jest suites (proxyHeaderAuth, proxyAuthGuard, proxyAuthConfig, upstream config): 4 suites, 82/82 tests pass
  • yarn workspace @hyperdx/api run build, then the PR-gate symbol guard: 10/10
  • Docker buildx --target prod, using the same contexts and args as gascity-image.yml (CODE_VERSION=2.42.0-gascity-9ba6586f593a; 9ba6586f differs from 2ae4cede only in gascity/smoke/smoke.sh): builds, node 22.22.3, USER node
  • In-image symbol guard, the exact gascity-image.yml script: 10/10 present, now including the advanced-capture refusal in config.js and the credential strip in clickhouseProxy.js.
  • gascity/smoke against the built image: FerretDB 2.5.0 and postgres-documentdb at pinned digests, capture servers standing in for the ClickHouse connection host and the OTLP endpoint, fake secrets, run as 1000:1000 with --cap-drop ALL.
    • auth 31/31, three times: at the default log level, at SMOKE_LOG_LEVEL=trace with OTEL_LOG_LEVEL=debug (several thousand stdout lines, including the OTLP exporter's debug dump of every record), and in the production posture (no HYPERDX_API_KEY, no OTLP endpoint).
    • telemetry 4/4: in that posture the SDK skips initialization in the API and the alert task, /api/config hands out no apiKey, and nothing is exported.
    • rotation 7/7 (current/previous overlap, then previous cleared; previous == current refuses to start without printing the value) and failclosed 3/3 (T16, T16b, T16c).
    • seed/verify-seed: the dashboard, saved search and alert read back (the alert shows the backfilled display name).
    • /ready returns 200 and there are 0 restarts.
    • Negative control, the same harness against the currently deployed 2.28 image: 16/21. The five failures are exactly the leaks fixed here: T13 (secret forwarded to the connection host), T13b (gate cookie forwarded), T14 (secret in stdout), T14b (session cookie in stdout), T15 (secret in the OTLP export). The new T16b also fails against c160553b, as expected: that build starts with advanced network capture on.

The smoke matrix covers:

  • T1–T4b: no secret, wrong secret (same length and other length), or empty secret
  • T5: provisioning
  • T6/T6b: bare-session refusal
  • T7: domain allowlist
  • T8/T8b: multi-valued and control-character identity headers
  • T9: identity switch
  • T10: password-login bypass
  • T11/T11b: the direct API port
  • T12: logout to the gate sign-out
  • T13–T15: the leak checks
  • T5b–T5d: canonical-case gate headers; a trailing-dot domain (401); a plus-address is its own identity, never linked to the base address
  • T9b: an identity switch issues a new session id (no fixation)
  • T13c, T14c–T14f, T15b: a bearer token and the gate cookie never reach the connection host, stdout or the OTLP export, including on the error-handler and console.error paths

Run on the prototype (this stack before review commits 13–15; from the design doc, not repeated here):

  • ClickHouse 26.3 queries with a production-shaped schema and DEFAULT_SOURCES, via /api/v2/search, /api/v2/charts/series and the browser /api/clickhouse-proxy

CodeQL on this PR

The CodeQL check reports 71 "new" alerts because the entire 2.42 release appears as changed code in this PR. 70 of them are on upstream code. The one on fork code was js/polynomial-redos on the gate email check in proxyHeaderAuth.ts. [^\s@,]+\.[^\s@,]+ is ambiguous because the label class also matches ., so a long dotted header value took about 5 s per request for 50k characters. That check was only reachable after the shared secret matched, and it was already in 2.28. Commit 12 replaces it with a linear structural check: at most 254 characters, no whitespace or comma, exactly one @, and two or more non-empty domain labels. 4 new tests cover it, and the pathological case drops from 4.7 s to 3 ms. Re-analysis on c160553b confirms the result: 70 alerts remain, none on fork-owned files or fork hunks. The remaining upstream alerts, mostly missing rate limiting and "query built from user-controlled sources", are unchanged by the fork and belong to upstream.

Dependency audit (osv-scanner on yarn.lock; the lockfile here is identical to the audited prototype's)

2.28 (current gascity-sso) 2.42 (this PR)
Distinct advisories, whole lockfile 248 (11 C / 113 H) 135 (5 C / 67 H)
…shipped in the image (runtime) 188 (8 C / 84 H) 78 (2 C / 40 H)
trivy, whole image 266 (9 C / 110 H) 156 (3 C / 66 H)
Open Dependabot alerts matched 225 of 231 126 of 231

Remaining runtime criticals. Both are next 16.3.8 advisories published 2026-10-22 and fixed in 16.4.1. Neither applies here:

Decision: wait for upstream 2.43 rather than override next here.

trivy's third critical is Go stdlib 1.23.7 inside the build-only esbuild binary, which is never executed. Most of the remaining highs are DoS-class and belong to upstream.

Migration and rollback safety

This was rehearsed on FerretDB 2.5.0 by running 2.28, then 2.42, then 2.28 again on the same database (see the prototype design notes).

  • 2.28 → 2.42. No migrate-mongo scripts. The only migration is an additive startup backfill of alert displayName/tags (logged Backfilled alert display names and tags), plus the new unique users.accessKey index and new alerts indexes. All of these built cleanly on FerretDB. The seeded dashboard, saved search, webhook and alert stayed readable, and the auth matrix passed 21/21.
  • 2.42 → 2.28 rollback. The data touched by 2.42 started cleanly, with no index or validation errors. All objects were readable (2.28 ignores the new fields) and auth worked.
  • ClickHouse. No DDL runs against BYO ClickHouse, and the connection is read-only.
  • Rollback target: the previous fork digest sha256:ded30d1c… (2.28.0-gascity-7b24ad4fa2f9), never stock upstream, which ignores PROXY_AUTH_*. Rolling back brings F1 and F2 back, so a lasting rollback must be followed by another secret rotation.

After merge

  1. Image. The push to gascity-sso runs gascity-image (verify → build → symbol guard → publish). Record the digest of ghcr.io/gascity/hyperdx:2.42.0-gascity-<sha12> from the job's ### published ### output. If the Blacksmith runner jobs sit queued, switch them to ubuntu-latest in a follow-up PR.
  2. Tag. Push the generation tag gascity-sso/2.28.0 → 7b24ad4f. The upgrade recipe in GASCITY-FORK.md uses it.
  3. Default branch. Switch the repository's default branch to gascity-sso. The 9 inherited upstream workflows are already disabled in Actions settings; two more (Claude Code, Deep Review Resolver) were disabled after GitHub registered them on the first issue_comment event. Only gascity-image and gascity-pr remain active, plus GitHub's dynamic CodeQL and Dependency Graph.
  4. Deploy. Finalize the deployment repin (digest, /ready readiness probe, HYPERDX_API_KEY removed), merge it, and verify.
  5. Rotate. Rotate the proxy shared secret, using the previous-secret overlap window from f567cb14, and the gate's cookie secret. Optionally purge the old log rows.
  6. Upstream. File the ClickHouse-proxy credential strip (53376697) with hyperdxio/hyperdx.

Supersedes #2. Its two commits are carried here as f567cb14 and a38ca3bc.

🤖 Generated with Claude Code

pulpdrew and others added 30 commits September 4, 2026 19:12
## Summary

This PR is part 1 of 3 in introducing alert-level name and tags.

**Context**: We require alert-level name and tags so that the alerts page can sort, filter, and paginate alert documents by name and tags without joining the full alerts collection with the dashboard and saved search collections.

In this PR:

1. Add `displayName` and `tags` to the alert model, types, and schemas. `displayName` is chosen because the existing `name` can be a handlebars template (confirmed that production data includes handlebars templates) and we don't want to show an unrendered handlebars template as an alert's name on the alerts page.
2. Functions for deriving an alert's name and tags from the dashboard tile or saved search it references, more or less matching how these values are derived today for the alerts page, and more or less matching this [backfill PR.](ClickHouse#3029)
3. When writing alerts through internal endpoints (create/update dashboard, create/update alert), if an explicit displayName or tags is provided in the request, use that value. If none is provided, write the name and tags derived from the referenced dashboard or saved search.
4. When reading /alerts or /alert/:id, return a stored displayName and tags if available, otherwise derive the displayName and tags from the referenced dashboard or saved search

In future PRs:

1. Part 2: External API and MCP support. Notification titles use displayName if available (and not overridden by `name`)
2. Part 3: UI updated to allow the user to specify alert's name and tags directly. Alerts page + alerts details page updated to show displayName and tags
3. Part 4: Backfill displayName and tags for existing alerts
4. Part 5+: paginate the alerts page

### Screenshots or video

<details>
<summary>Create dashboard alert</summary>

Request is sent without alert-level displayName or tags, the value saved in Mongo is derived from the dashboard+tile names, and the dashboard's tags

<img width="800" height="363" alt="Screenshot 2026-09-03 at 8 30 53 AM" src="https://github.com/user-attachments/assets/350fc85c-7f71-4d5b-9963-c8360845349d" />
<img width="381" height="199" alt="Screenshot 2026-09-03 at 8 30 00 AM" src="https://github.com/user-attachments/assets/4e435321-7d4d-4654-9e37-48de77c3b87c" />

If displayName or tags are included in the request, they are persisted, instead of a derived displayName and tags:

<img width="360" height="93" alt="Screenshot 2026-09-03 at 8 36 05 AM" src="https://github.com/user-attachments/assets/f0bea253-4453-4dfb-9787-7e1d00da66f4" />

</details>

<details>
<summary>Update dashboard alert</summary>

If an existing tile alert has no persisted displayName or tags, and the request doesn't include one, then the derived values will be saved

<img width="332" height="65" alt="Screenshot 2026-09-03 at 8 33 56 AM" src="https://github.com/user-attachments/assets/7ee69581-6eb4-47a0-88ee-2503ef429bc1" />

If an existing tile is updated and a displayName or tags are included in the request, they are persisted, instead of a derived displayName and tags:

<img width="360" height="93" alt="Screenshot 2026-09-03 at 8 36 05 AM" src="https://github.com/user-attachments/assets/f0bea253-4453-4dfb-9787-7e1d00da66f4" />

</details>

<details>
<summary>Create saved search alert</summary>

Derived values are saved in Mongo

<img width="294" height="76" alt="Screenshot 2026-09-03 at 8 44 56 AM" src="https://github.com/user-attachments/assets/295aa546-05d2-4ef7-9969-b19dbc0d2fd7" />
<img width="553" height="269" alt="Screenshot 2026-09-03 at 8 45 20 AM" src="https://github.com/user-attachments/assets/4b904ca3-309e-4a6a-b32a-5fb52c7cf821" />

</details>


<details>
<summary>Update saved search alert</summary>

Derived values are saved in Mongo, if no displayName/tags are provided in the request

<img width="553" height="269" alt="Screenshot 2026-09-03 at 8 45 20 AM" src="https://github.com/user-attachments/assets/71c53bbd-673b-4ff7-b9b7-45488187f829" />
<img width="259" height="58" alt="Screenshot 2026-09-03 at 8 47 00 AM" src="https://github.com/user-attachments/assets/a63487da-bb2b-4cae-8090-2e8ccf41c0a4" />

</details>

<details>
<summary>GET alerts</summary>

Derived values are returned for displayName / tags if not present in the DB

<img width="915" height="911" alt="Screenshot 2026-09-03 at 8 48 20 AM" src="https://github.com/user-attachments/assets/076460e3-de1f-4a69-9f43-a6f8a92c9422" />

Persisted values are returned, if present

<img width="884" height="362" alt="Screenshot 2026-09-03 at 8 49 45 AM" src="https://github.com/user-attachments/assets/894032b8-cee9-405f-93da-b757d6dac023" />

</details>

<details>
<summary>GET alert/:id</summary>

Derived values are returned if no persistent values exist

<img width="732" height="444" alt="Screenshot 2026-09-03 at 8 50 26 AM" src="https://github.com/user-attachments/assets/622b12e6-de79-4710-99be-65aad62d7fec" />

Persisted values are returned if present

<img width="759" height="444" alt="Screenshot 2026-09-03 at 8 50 40 AM" src="https://github.com/user-attachments/assets/69ed40a7-d8fd-40d8-bc99-546646a40bd7" />

</details>

### How to test locally

Create and update alerts on dashboard tiles and saved searches, observe the displayName and tags values persisted in mongodb.

Observe the response from the `/alerts` and `/alerts/:id` internal endpoints, they should include a derived displayName and tags, or the stored value if it's available.

### References



- Linear Issue: Related to HDX-5116
- Related PRs:
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.


# Releases
## @hyperdx/cli@0.6.3

### Patch Changes

- 53336f7: fix(charts): show decimals on Y-axis ticks under 10 in magnitude

  The Y-axis of a time series chart (and the CLI's termchart equivalent)
  always rounded tick labels to 0 decimal places, regardless of the chart's
  Number Format settings. Charts whose values live under 1 (fractional
  gauges, ratios, sub-1 rates) rendered every axis tick as `0` even though
  the tooltip and legend showed the correct value.

  A tick under 10 in magnitude (as displayed - a percent tick's magnitude is
  checked against its ×100 value, not its raw 0-1 ratio) now honors the
  chart's configured decimals, capped at 2 to keep the label within the
  axis's fixed width. A tick of 10 or more, and a tick of exactly 0, stay
  integers exactly as before, whatever the chart's Number Format configures

  - so ordinary counts and the byte/percent tiles in the bundled dashboard
    templates are unaffected.
## @hyperdx/api@2.38.0

### Minor Changes

- 74c28e7: feat: Alerts now persist their own `displayName` and `tags`
- 9c4f94f: Add enriched template variables to Generic and incident.io webhook bodies:
  `{{alertId}}`, `{{status}}`, `{{alertType}}`, `{{comparator}}`, `{{threshold}}`,
  `{{value}}`, `{{groupKey}}`, `{{sourceQuery}}`, `{{teamId}}`, `{{note}}`, and
  ISO-8601 `{{startTimeISO}}` / `{{endTimeISO}}` alongside the existing Unix-ms
  `{{startTime}}` / `{{endTime}}`.

  Receivers can now route, filter and dedupe on an alert's identity and condition
  without parsing the rendered message body. Existing templates are unaffected —
  every new variable is additive and renders empty when an alert doesn't carry it.

- f0d1cef: Support inline chart alerts (source `inline` + `chartConfig`) in the external
  API v2 and the MCP `clickstack_save_alert` / `clickstack_get_alert` tools.
  Inline alerts can now be created, updated, listed, and deleted through
  `/api/v2/alerts` using the same tile-config dialect as v2 dashboards, with the
  same validation rules as the internal API: display-type allowlist, metric
  formula validation, the formula source-kind gate, raw SQL template validation,
  and team-scoped source/connection ownership. Passing a `chartConfig` to a
  `tile` or `saved_search` alert is now rejected instead of silently dropped,
  and reading an inline alert whose config carries internal-only fields omits
  `chartConfig` rather than returning a lossy approximation.

  Also fixes three pre-existing issues on the external v2 dashboards path: a
  gauge tile saved through `clickstack_save_dashboard` with `isDelta: true` was
  persisted as a non-delta (the flag was dropped converting MCP tiles to the
  internal shape); a tile config carrying an unrecognized `configType` (e.g.
  `promql`) was silently stored as a builder config and skipped the formula and
  number-select rules, and is now rejected; and validation errors on alert
  bodies no longer collapse to `Invalid input` — the schema reports the failing
  branch's own message.

- d9c5c45: fix: preserve a Connection host path prefix when proxying PromQL.
  `proxyToPrometheus` joined absolute Prometheus paths (`/api/v1/query_range`,
  `/api/v1/query`, `/api/v1/query_exemplars`, `/api/v1/label/.../values`) with
  `new URL(path, host)`, which replaces the host pathname instead of appending to
  it. VictoriaMetrics cluster `vmselect` URLs such as
  `http://vmselect:8481/select/0/prometheus` were rewritten to
  `/api/v1/query_range` and rejected. The join now keeps the existing pathname.

  This is a behavior change for Connections whose host already included a path
  that was never meant as a Prometheus API prefix — for example
  `http://prom:9090/graph` copied from the Prometheus UI. That previously happened
  to work because the absolute API path replaced `/graph`; requests now go to
  `/graph/api/v1/query_range` and will 404. Trim stray paths from existing
  Connection hosts before upgrading. Root-mounted hosts (`http://prom:9090` or
  `http://prom:9090/`) are unchanged.

  Query parameters on the Connection host are now only a fallback for a fixed set
  of real Prometheus API params (`query`, `time`, `start`, `end`, `step`,
  `match`/`match[]`, `limit`, `timeout`, `stats`): a request value for one of
  these (including repeatable ones such as `match[]`) always wins and replaces a
  same-named host value outright, rather than being dropped. Any other host query
  key the request never mentions -- for example `?extra_label=namespace%3Dprod`
  pinning a VictoriaMetrics tenant scope -- is left as-is and is never overridable
  by the request, since a param name outside that fixed set is not forwarded at
  all regardless of what the host carries. This also means a host copied with a
  stray query string (not just a stray path) now forwards its non-Prometheus keys
  upstream as a fallback on every request -- trim those too if they weren't
  intended as Prometheus API params.

  This is also a behavior change for a direct API caller (e.g. curl or Terraform)
  that previously relied on sending an arbitrary, non-Prometheus query param
  through this endpoint: that param is now silently dropped rather than forwarded,
  regardless of whether the Connection host carries anything under the same name.

### Patch Changes

- 25a3b01: Fix `{{sourceQuery}}` returning empty for inline-query and dashboard-tile
  alerts. It read only the saved search's filter, so alerts backed by a chart
  config — where the query lives on the alert or the tile — advertised a variable
  that never rendered. It now resolves the query from whichever config backs the
  alert: the builder `where` or the raw `sqlTemplate`.

  Add `{{thresholdMax}}`, the upper bound of a `between` / `outside` condition.
  Receivers previously saw only the lower bound and could not reconstruct the
  range that fired. It renders empty for every other comparator.

  Test Webhook now sends a sample value for every template variable. It carried
  only the original seven, so a body using an enriched variable rendered it empty
  — and because `threshold`, `thresholdMax` and `value` are emitted unquoted, a
  body like `{"value": {{value}}}` was sent as `{"value": }` and rejected,
  failing the test for a template that works on a real firing.

- 808b345: Accept `Bearer `-prefixed Authorization header values on the OTel ingest endpoint in OpAMP-managed mode. The collector's bearer-token authenticator matches the full header value exactly and was configured with only the bare API key, so RFC 6750 clients that send `Authorization: Bearer <token>` were rejected. The generated collector config now also accepts `Bearer`, `bearer`, and `BEARER` prefixed forms of each ingestion API key.
- bf4443d: feat: Support autocomplete for PromQL label filters
- 55db91f: feat: Support static filters in MCP
- 89a897e: Fixed single-series histogram charts failing with "Unknown expression or function identifier" when sorted by a group-by column or expression. The histogram translation packs group values into a single `group` Array, so the table default ORDER BY (the raw group-by text) referenced source columns that no longer exist in scope; matched sort items now address the packed array positionally.
- f1062a7: Fixed multi-series metric charts failing with "Unknown expression or function identifier" when sorted by an expression group-by (e.g. `ResourceAttributes['service.name']`). Table tiles default their ORDER BY to the group-by text, so any multi-series metric table grouped by a resource/attribute-derived expression failed to render. Such sort expressions are now evaluated inside each per-series branch through internal companion columns instead of being re-evaluated in the composed outer query, where the source columns no longer exist.
- 4184a89: feat: Support dashboard filters based on Prometheus label values
- 2736003: feat: Support series filter (matcher) in PromQL label dashboard filters
- 7ed8dc8: feat: Evaluate Prometheus `match[]` series selectors on the labels and label values endpoints for ClickHouse TimeSeries connections
- bcf0257: feat: Accept optional time bounds on Prometheus label values endpoint
- cff6388: feat: Add static filters to schemas and APIs
- Updated dependencies [74c28e7]
- Updated dependencies [b917308]
- Updated dependencies [55db91f]
- Updated dependencies [89a897e]
- Updated dependencies [f1062a7]
- Updated dependencies [4184a89]
- Updated dependencies [2736003]
- Updated dependencies [c796592]
- Updated dependencies [cff6388]
  - @hyperdx/common-utils@0.28.1
## @hyperdx/app@2.38.0

### Minor Changes

- 503aac8: Show GPU utilization and GPU memory utilization charts in the log/span side
  panel Infrastructure section when `hw.gpu.*` metrics (OTel hardware semconv)
  exist for the correlated host/node. Multiple GPUs on a host render as separate
  series grouped by `hw.id`, and utilization is split per GPU engine by
  `hw.gpu.task` (general/encoder/decoder) so a node saturated on video encode is
  still visible; a missing task is reported as `general`. The section is fully
  hidden when no GPU metrics are present and partially rendered when only one
  metric is available.

  The Infrastructure tab now also treats a Kubernetes resource attribute that is
  present but empty (for example `k8s.node.name: ""`) as absent. Such rows
  previously surfaced an Infrastructure tab that could render nothing.

  Fix GPU chart availability leaking across rows: switching the side panel to a
  row on a different host briefly rendered the previous host's set of GPU
  charts, because the availability query keeps the prior result readable while
  the new one runs.

- 38e99a3: Add a read-time, schema-agnostic LLM observability dashboard at `/llm` (beta, linked from the dashboards list) — no ingestion changes required, and it works retroactively on already-ingested telemetry. Traces instrumented with the OTel GenAI semantic conventions, OpenLLMetry, OpenInference, or the Vercel AI SDK are interpreted at query time to chart traffic, token split (uncached/cached/cache-write/output/reasoning), estimated cost by model (bundled price catalog with cache-read discounts and cache-write premiums; an instrumentation-provided cost attribute always wins), latency/TTFT, tool analytics, per-session timelines with lazy-loaded conversation views, and side-by-side LLM span/log search. Aggregations elect an app's own cost-reporting spans as the authoritative per-call reporters so apps emitting several instrumentation dialects in parallel (e.g. opencode emitting OpenInference and Vercel AI spans for each call) are counted once — verified to match opencode's self-reported session cost exactly.

### Patch Changes

- 3f7934a: chore: Add isSourceAllowed prop to SourceSelectControlledComponent
- b917308: fix: metric names in the chart editor are now listed deterministically instead of sampled. The dropdown discovered names with `groupUniqArray(3000)(MetricName)`, which keeps an arbitrary subset once a metrics table holds more than 3000 distinct names — the survivors follow hash order, not name order — so metrics that exist and are actively reporting could be unselectable, with no warning and no way to search for what had been dropped. Names are now fetched with an ordered, paginated query and matched server-side, ranked so an exact match is always on the first page, and the dropdown says when the list is incomplete. Also fixes the metric list ignoring the chart's selected time range, which pinned it to the last 24 hours.
- bf4443d: feat: Support autocomplete for PromQL label filters
- 55db91f: feat: Support static filters in MCP
- 89a897e: Fixed single-series histogram charts failing with "Unknown expression or function identifier" when sorted by a group-by column or expression. The histogram translation packs group values into a single `group` Array, so the table default ORDER BY (the raw group-by text) referenced source columns that no longer exist in scope; matched sort items now address the packed array positionally.
- fbb1e20: Preserve conditional color rules for SQL and PromQl chart configs
- 020e85f: Fix two problems with the chart editor's metric name picker. The truncation and load-failure notices now render below the input instead of above it, so they no longer push the field down out of alignment with the browse-metrics button beside it, and both were shortened to fit the tile editor's column without wrapping. A search that matches nothing now says so rather than silently hiding the dropdown, and offers the searched name as a selectable option — the catalog only covers the most recent three days of the chart's time range, so a metric that stopped reporting was previously impossible to chart by name.
- f1062a7: Fixed multi-series metric charts failing with "Unknown expression or function identifier" when sorted by an expression group-by (e.g. `ResourceAttributes['service.name']`). Table tiles default their ORDER BY to the group-by text, so any multi-series metric table grouped by a resource/attribute-derived expression failed to render. Such sort expressions are now evaluated inside each per-series branch through internal companion columns instead of being re-evaluated in the composed outer query, where the source columns no longer exist.
- cdcb023: fix: Reliably show service name and level in pattern sample drawer
- 0ebb689: fix: De-dupe PromQL labels and values to prevent Mantine crash
- 4184a89: feat: Support dashboard filters based on Prometheus label values
- 2736003: feat: Support series filter (matcher) in PromQL label dashboard filters
- bcf0257: feat: Accept optional time bounds on Prometheus label values endpoint
- c796592: feat: Support a custom template for PromQL series legends
- 83c1b57: feat: Display static value filters
- 66f6cf0: Add a `window.hdx` browser-console debug handle and a "Copy debug info" action in the Help menu, so you can confirm which build is deployed and grab the context worth attaching when filing an issue. `window.hdx.report()` (and the Help menu action) produces a pasteable summary: frontend version (from package.json), backend/API version (from `/api/health` — the two deploy separately), deployment mode, user/team ids, enabled env-configurable feature flags, current URL, screen/viewport/OS/browser info, and the RUM session id. The handle is installed once and reads its async fields (server version, identity, features, session id) live via getters.
- cff6388: feat: Add static filters to schemas and APIs
- 53336f7: fix(charts): show decimals on Y-axis ticks under 10 in magnitude

  The Y-axis of a time series chart (and the CLI's termchart equivalent)
  always rounded tick labels to 0 decimal places, regardless of the chart's
  Number Format settings. Charts whose values live under 1 (fractional
  gauges, ratios, sub-1 rates) rendered every axis tick as `0` even though
  the tooltip and legend showed the correct value.

  A tick under 10 in magnitude (as displayed - a percent tick's magnitude is
  checked against its ×100 value, not its raw 0-1 ratio) now honors the
  chart's configured decimals, capped at 2 to keep the label within the
  axis's fixed width. A tick of 10 or more, and a tick of exactly 0, stay
  integers exactly as before, whatever the chart's Number Format configures

  - so ordinary counts and the byte/percent tiles in the bundled dashboard
    templates are unaffected.

- 25a3b01: List every supported template variable in the webhook form, including the
  enriched set added to Generic and incident.io bodies (`{{alertId}}`,
  `{{status}}`, `{{alertType}}`, `{{comparator}}`, `{{threshold}}`,
  `{{thresholdMax}}`, `{{value}}`, `{{groupKey}}`, `{{sourceQuery}}`,
  `{{teamId}}`, `{{note}}` and ISO-8601 `{{startTimeISO}}` / `{{endTimeISO}}`).
  Each variable now carries a one-line description, so a webhook body can be
  written without leaving the form.
- 0a18745: feat: Allow creation and editing of static list filters in the UI
- Updated dependencies [74c28e7]
- Updated dependencies [25a3b01]
- Updated dependencies [9c4f94f]
- Updated dependencies [808b345]
- Updated dependencies [b917308]
- Updated dependencies [bf4443d]
- Updated dependencies [55db91f]
- Updated dependencies [89a897e]
- Updated dependencies [f0d1cef]
- Updated dependencies [f1062a7]
- Updated dependencies [4184a89]
- Updated dependencies [2736003]
- Updated dependencies [7ed8dc8]
- Updated dependencies [bcf0257]
- Updated dependencies [c796592]
- Updated dependencies [d9c5c45]
- Updated dependencies [cff6388]
  - @hyperdx/api@2.38.0
  - @hyperdx/common-utils@0.28.1
## @hyperdx/common-utils@0.28.1

### Patch Changes

- 74c28e7: feat: Alerts now persist their own `displayName` and `tags`
- b917308: fix: metric names in the chart editor are now listed deterministically instead of sampled. The dropdown discovered names with `groupUniqArray(3000)(MetricName)`, which keeps an arbitrary subset once a metrics table holds more than 3000 distinct names — the survivors follow hash order, not name order — so metrics that exist and are actively reporting could be unselectable, with no warning and no way to search for what had been dropped. Names are now fetched with an ordered, paginated query and matched server-side, ranked so an exact match is always on the first page, and the dropdown says when the list is incomplete. Also fixes the metric list ignoring the chart's selected time range, which pinned it to the last 24 hours.
- 55db91f: feat: Support static filters in MCP
- 89a897e: Fixed single-series histogram charts failing with "Unknown expression or function identifier" when sorted by a group-by column or expression. The histogram translation packs group values into a single `group` Array, so the table default ORDER BY (the raw group-by text) referenced source columns that no longer exist in scope; matched sort items now address the packed array positionally.
- f1062a7: Fixed multi-series metric charts failing with "Unknown expression or function identifier" when sorted by an expression group-by (e.g. `ResourceAttributes['service.name']`). Table tiles default their ORDER BY to the group-by text, so any multi-series metric table grouped by a resource/attribute-derived expression failed to render. Such sort expressions are now evaluated inside each per-series branch through internal companion columns instead of being re-evaluated in the composed outer query, where the source columns no longer exist.
- 4184a89: feat: Support dashboard filters based on Prometheus label values
- 2736003: feat: Support series filter (matcher) in PromQL label dashboard filters
- c796592: feat: Support a custom template for PromQL series legends
- cff6388: feat: Add static filters to schemas and APIs
## @hyperdx/otel-collector@2.38.0

### Patch Changes

- 808b345: Accept `Bearer `-prefixed Authorization header values on the OTLP receiver in standalone mode (`OTLP_AUTH_TOKEN`). Previously only the bare-token form of the header was accepted, rejecting RFC 6750 clients that send `Authorization: Bearer <token>`. The `Bearer`, `bearer`, and `BEARER` prefixed forms are now accepted alongside the bare token.
…use#3044)

## Summary
- Adds the OpenTelemetry `spanmetricsconnector` to HyperDX's custom collector build.
- Enables users to derive span-based RED metrics — request count, errors, and latency — from ingested traces (most useful for Datadog Agent traces, where these metrics aren't otherwise available in the current HyperDX ingestion path).
- Supports exponential histograms, which HyperDX already stores natively.
- The change is additive and opt-in; it does not modify any default collector pipelines or runtime behavior.
- Documents how to wire the connector in both standalone and OpAMP-managed deployments.
- Includes the required changeset for `@hyperdx/otel-collector`.

## Test plan
- Built the collector using the repo's documented Docker build flow.
- Verified `spanmetricsconnector v0.155.0` is present in the resulting binary.
- Validated the documented standalone and OpAMP configurations against the compiled collector.

<details>
<summary>Implementation notes / OpAMP wiring details</summary>

In OpAMP mode, `receivers`/`exporters` on the default `traces`/`metrics` pipelines are supplied dynamically and overwrite the corresponding values from `CUSTOM_OTELCOL_CONFIG_FILE`. Because of that, span-metrics wiring must use separate pipeline names rather than modifying the default pipelines.

The documented config sets a few fields beyond the bare minimum: `namespace` (pins the connector's own default for explicitness), `histogram.exponential.max_size` (opts into the exponential histogram this PR is motivated by, vs. the connector's default explicit-bucket histogram), `aggregation_cardinality_limit` + `series_expiration` (bounds the connector's own in-memory state, which `memory_limiter` doesn't cover), and `aggregation_temporality: DELTA` (cumulative temporality plus expiration can reset a low-traffic series' counter in a way HyperDX's rate SQL doesn't detect; delta avoids the issue).

Two specific claims came up more than once across review and were checked directly against the pinned v0.155.0 source and the compiled binary — both turned out to be incorrect, noted here so they aren't re-litigated: the connector's canonical config key is `span_metrics` (`spanmetrics` is a working deprecated alias, not the other way around), and `series_expiration` is a real, distinct field from `metrics_expiration`.

</details>

## Follow-up

An end-to-end smoke test that emits a span and asserts generated `calls`/`duration` metrics reach ClickHouse would provide additional coverage beyond collector config validation.
)

## Summary

This PR introduces support for marking dashboard filters (variables) as required, indicating that some or all of the dashboard should not load until values are selected for the filters. 

Two new fields across each filter type:

1. `minSelections`: a value of 1 indicates that the filter is required. I chose a number so that (a) we can expose a consistent `maxSelections` in the future for capping selections and (b) we can express a minimum >1 in the future if we want to
2. `isGlobalRequirement`: true indicates that the filter is required for any/every tile on the dashboard to load. The default (false) implies that only tiles that reference the required filter as a variable or receive a broadcasted value from the filter are blocked by the requirement.

Note: dependent filters are not yet blocked by required filters that they depend on.

### Screenshots or video

https://github.com/user-attachments/assets/fba44693-7677-450e-a196-fa2b4c19b297

### How to test on Vercel preview

1. Create a dashboard
2. Add some tiles and some filters
3. Play around with the new required settings, variable references, and broadcasting settings

### References



- Linear Issue: Closes HDX-5292
- Related PRs:
## Summary

This PR updates the team/tags endpoint to return tags from Alert documents, in addition to Dashboards and Saved Searches (existing behavior). Now that Alerts can be tagged independently of dashboards and saved searches, this is necessary so that if a tag is use exclusively by an alert, that tag appears in the suggested tags for other alerts.

There is no existing index on tags in Dashboards or Saved Searches, so this update is consistent with that for alerts.

### Screenshots or video

<img width="289" height="364" alt="Screenshot 2026-09-08 at 9 50 51 AM" src="https://github.com/user-attachments/assets/211e67aa-6c04-431d-ba32-abdbb36dcc82" />


### How to test locally

1. Create an alert and add a new tag to it
2. Create another alert and notice that the tag added to the first alert is present in the tag dropdown

### References



- Linear Issue: Closes HDX-5287
- Related PRs:
…kHouse#3093)

## Summary

This PR adds an option in the dashboard tile editor that propagates the current dashboard filter and variable selections into the chart preview. This is toggled on by default, and disabled when the user toggles it off or when there is an alert configured on the tile (since alerts evaluate without any filters).

Motivation:

1. Variables and core filters (EE-only, currently) were already propagated but broadcast filters weren't; this was an inconsistency
2. Now that we have required filters (ClickHouse#3078), it may be desirable for performance reasons to encourage the user to apply required filters when editing the tile. This will not fully block the user from making unfiltered queries, as that requires further modifications which are the domain of core filters in EE.

### Screenshots or video

https://github.com/user-attachments/assets/451613ba-0e8a-4d9f-93ad-23056228e78b

### How to test on Vercel preview

1.  Create a dashboard, add a filter
2. Open the tile editor under different combos of filter-required modes, variable/broadcast modes, sources, configured alerts, display types, and play around with the toggle.

### References



- Linear Issue: Closes HDX-5325
- Related PRs:
## Summary

Updates the trace schema to use text indexes instead of bloom filters or tokenbf_v1.

### References

- Linear Issue: Closes HDX-5337
…d copy (ClickHouse#3098)

## Summary

Addresses two comments from #3903:

1. Update the copy in the tile editor when the preview is blocked by required variables to point the user towards turning off Apply Filters as an option to bypass the block
2. Extract a shared getBlockingRequiredFilterNames function wrapping two similar calls to `getBlockingRequiredFilters`

### Screenshots or video

New Copy:

<img width="2069" height="243" alt="Screenshot 2026-09-09 at 7 54 32 AM" src="https://github.com/user-attachments/assets/2269ff9e-1e56-4480-afcd-10a605560231" />


### How to test on Vercel preview

1. Create a dashboard, add a filter and set it to required
2. Create a tile that uses that filter, check that the preview is disabled with the new message

### References



- Linear Issue:
- Related PRs:
…s has no nested _id (ClickHouse#3099)

## Summary

Fixes ClickHouse#3094

Fixes a fetch / mount loop when viewing the trace waterfall panel on sources with metadata MVs. The issue was that Mongo would add a freshly generated `_id` to each metadataMaterializedViews object, when the persisted source had none. This _id is part of the object that exists in the react query key for various queries, so when it changes, old queries are canceled and new ones start. This led to the UI switching from loading to loading state, unmounting and remounting components as the loading state toggled back and forth.

The fix removes the _id from the metadataMaterializedViews sub-schema so that a fresh one is not generated on each read.

Reproduction:

1. Create a traces source and add metadata MVs. Save
2. Change the source's type to Log. Save.
3. Change the source's type back to Trace. Fill in any fields that were removed. Save.
4. Navigate to the waterfall for the source.


### Screenshots or video

**Before:**

Fetch/mount loop:

https://github.com/user-attachments/assets/299c8eeb-7cc0-43fa-8cc5-e8be84419e99

**After:**

Waterfall loads:

<img width="2558" height="1235" alt="Screenshot 2026-09-09 at 11 52 16 AM" src="https://github.com/user-attachments/assets/dd5c22a9-afe4-494f-829c-342e2f1aa748" />


### References



- Linear Issue:
- Related PRs:
…e#3069)

## Summary

[HDX-5090](https://linear.app/clickhouse/issue/HDX-5090/support-creating-alerts-without-saved-searches-or-dashboard-tiles)'s backend ([ClickHouse#3010](ClickHouse#3010)) added `AlertSource.INLINE` — alerts that persist their own `chartConfig` and evaluate with no saved search or dashboard tile behind them. The internal `/alerts` API accepts and returns them, but no UI could create or edit one. This is that UI.

The motivating case is Epidemic Sound migrating 1000+ Grafana rules: today every alert needs a saved search (logs) or a dashboard tile (metrics) created first, which does not scale.

**Chart explorer** (`/chart`)
- The alert affordances are gated on a new `enableAlerts` prop rather than on being inside a dashboard (previously `dashboardId != null`), and a **Create alert** action in the action bar POSTs `{ source: 'inline', chartConfig, ...alertFields }`. Works for builder configs (log/trace/metric) and raw SQL, on Line / Stacked bar / Number — the display types the evaluator supports.
- Naming comes from the shared `AlertDisplayFields` (name + tags) that ClickHouse#3063 added to the alert editor, so an inline alert is labelled the same way as any other. Left blank, the server derives the name from the chart config — it already handles `AlertSource.INLINE` in `deriveAlertDisplayFields`.
- On success the alert is dropped from the explorer's URL config — it now lives on the alert document, and leaving it there would offer to create a second copy.

**Alerts list**
- Inline alerts render their name with a chart icon and link to the chart explorer as `/chart?alertId=<id>`, which the explorer resolves into the persisted query. By id rather than by an inlined config because `GET /alerts` omits `chartConfig` — a config-carrying link would leave every list row without one whenever the alert detail page is disabled. It also opens the query as it stands now rather than a snapshot. (The notification link still inlines the config: it is built server-side, with no session to fetch with, and lands on the same chart.)
- The row name renders as plain text when no destination resolves instead of a link to an empty `href`. That also covers a pre-existing case: a tile alert whose dashboard was deleted, with the details page disabled.
- Banner and empty-state copy now mention the chart explorer.

**Alert detail page**
- The chart renders from the persisted `chartConfig`. The tile variant's config assembly is extracted into a shared `buildAlertChartConfig`, so the two previews cannot drift.
- Editing opens the full chart editor seeded from the alert (`EditInlineAlertModal`), so the query and the alert's threshold/channels/schedule are changed in one place, then split apart again on save. The remove-alert control is hidden there, and "Save to dashboard" is suppressed — saving that chart as a tile would copy its alert onto the tile, leaving two alerts on one query.
- `GET /alerts` deliberately omits `chartConfig`, so an alert opened from a list row fetches its detail response before seeding the editor.
- `EditAlertModal` keeps handling saved-search and tile alerts. An inline alert routed there would previously have fallen through to the saved-search branch and been rewritten as a saved-search alert with no saved search; it now round-trips its config instead.

**Not in scope** (matching the backend PR): External API v2, MCP write support, and Terraform for inline alerts.

### Screenshots or video



| Before | After |
| :----- | :---- |
|        |       |

### How to test on Vercel preview

N/A — the preview runs in `LOCAL_MODE`, which has no API server behind it, and alert creation is gated off there (`!IS_LOCAL_MODE`). Exercise this against a full-stack dev server:

1. Open `/chart`, pick a source, and build a Time series or Number chart.
2. Click **Add Alert**, give it a name and a webhook, then click **Create alert**.
3. Open `/alerts` — the alert is listed with a chart icon, and its name links back to the explorer with the same query. Works with `NEXT_PUBLIC_ENABLE_ALERT_DETAILS` both on and off; with it on, the detail page header and the row menu's **Open chart** use the same link.
4. Open the alert's details page (requires `NEXT_PUBLIC_ENABLE_ALERT_DETAILS`) — the query chart renders with the threshold line. **Edit alert** opens the chart editor; change the threshold and the query, save, and confirm both persist.

### References

- Linear Issue: [HDX-5192](https://linear.app/clickhouse/issue/HDX-5192/ui-for-creating-and-editing-chart-alerts-chart-explorer-alert-details)
- Related PRs: [ClickHouse#3010](ClickHouse#3010) (backend)

---

**Testing done:** `make ci-lint`, `make ci-unit` — pass (3637 app tests). New unit tests cover the inline-alert helpers, the explorer's alert gating / name field / save payload, the `?alertId=` seeding hook, the detail-chart config builder, and the inline edit modal's seeding, PUT payload, and lazy fetch.

*Note:* the ticket calls the source `AlertSource.CHART`; the merged backend named it `AlertSource.INLINE`, which is what this follows.
…x-eligible (ClickHouse#3104)

## Summary

Two changes to the LLM dashboard, both touching how its `WHERE` clauses are built.

**Performance.** Every tile carries a `WHERE` testing whether attribute keys are present, expressed as `SpanAttributes['key'] != ''` — the one shape that can't be indexed. `!= ''` normalizes to `notEmpty()`, which no skip index supports, so every granule is scanned. Map subscripts are also subcolumn references, so on ClickHouse 26.3+ each distinct key adds a per-part size lookup during PREWHERE planning (HDX-5348). These filters now lead with `mapContains`, which the `mapKeys()` index serves and which costs no per-part lookups.

**Feature.** Adds a user filter beside the existing session filter, so the dashboard can be scoped to one end user.

<details>
<summary>Index and function references</summary>

ClickHouse's text index condition ([`MergeTreeIndexConditionText::isSupportedFunction`](https://github.com/ClickHouse/ClickHouse/blob/master/src/Storages/MergeTree/MergeTreeIndexConditionText.cpp)) serves `mapContainsKey` and `has()` against a `mapKeys()` index; a `bloom_filter` over `mapKeys()` answers the same shape. ClickHouse#3096 moved `idx_span_attr_key` to `text(tokenizer='array')`, which makes this pointed: the index is there and these queries couldn't use it.

`mapContains` is a case-sensitive alias of `mapContainsKey` ([FunctionsMapMiscellaneous.cpp:766](https://github.com/ClickHouse/ClickHouse/blob/master/src/Functions/array/FunctionsMapMiscellaneous.cpp)).

</details>

## Measured on staging

SharedMergeTree, 26.6.1, 162 active parts. `EXPLAIN` only — no rows read.

**Planning cost**, 10-key detection predicate (`isLLMSpan`), subcolumn sizes forced on:

| | duration | `S3GetObject` | outcome |
| :--- | ---: | ---: | :--- |
| `!= ''` | 36,111 ms | 1,480 | `TIMEOUT_EXCEEDED` before start |
| `mapContains` | **7 ms** | **0** | ok |

**Granule pruning**, 2-key predicate over 6h (`EXPLAIN indexes = 1`):

| | parts | granules | skip index |
| :--- | ---: | ---: | :--- |
| `!= ''` | 19 | 1,306 | *no `Skip` section* |
| `mapContains` | 3 | **3** | `idx_span_attr_key` |

**Equivalence**: both forms return `66` over 7 days (248M rows scanned).

## How the filters are shaped

Review moved this on from the first version, which was bare `mapContains` everywhere. Presence alone admits a key explicitly set to `''`, and most of these gates are paired with a value expression the dashboard *groups by* — so an empty `user.email` became a blank "Top Users" row whose drill-in link went nowhere.

Gates that feed a group-by therefore keep their value check, and the presence term is pruning only:

```sql
indexHint(mapContains(a,'k1') OR mapContains(a,'k2')) AND coalesce(nullif(a['k1'],''), …) != ''
```

The value term defines the result; the presence term reaches skip-index analysis without being re-evaluated per surviving row. `indexHint` is the existing repo idiom for this (`mapKeyIndexExpression`, queryParser.ts:1728). It costs no extra per-part lookups, because the value term reads the same keys the group-by already reads.

Three positional rules fell out of this, each with a test pinning it:

- **`isLLMSpan` is bare presence, not hinted.** `indexHint` returns true at execution, so hinting a filter that *is* the presence check would match every span. This gate is where the planning win comes from.
- **Select-list gates are unhinted.** Skip-index analysis never reaches the select list, so a hint there folds to a constant while lengthening every query. `hasReportedTokens` is unhinted for this reason; `isToolSpan` is used in both positions and is exposed in both forms.
- **Hints sit above an `OR`, never inside one.** A skip index can only drop a granule when every arm of an `OR` is decidable by it, so `isToolSpan`'s hint is hoisted to cover the span-kind key too — sound because both arms imply one of the hinted keys.

## User filter

Follows the session filter exactly: a dropdown of the distinct values seen in the searched range, applied centrally in `baseLLMChartConfig` so every tab picks it up, and held in the URL rather than the search form so it can be set programmatically without two-way sync.

Users resolve through the same cross-dialect expression "Top Users" groups by (`user.email`, `enduser.id`, `user.id`, `ai.telemetry.metadata.userId`), so a dropdown value always matches the rows that produced it. The Errors tab's correlated **log** query is scoped too, matched against the log source's own attribute column.

`SessionSelect` and `UserSelect` are now thin wrappers over a shared `DistinctValueSelect`. That extraction also fixed a bug pre-existing in `SessionSelect`: an applied value absent from the fetched options fell back to the placeholder, so the control read "All users" while every chart stayed filtered. Absence isn't only staleness — it also happens before the query resolves (deep-linking a filtered URL) and when the value falls off the row limit — so the applied value is kept selectable rather than cleared.

`buildSessionCondition` is now `buildScopeCondition`: it was already generic, and there are two scopes.

## Behavior changes

**LLM span detection is presence-based**: a span carrying `gen_ai.system` at all is an LLM span whatever the value. Nothing groups by that predicate, so it can't produce a blank row. On staging this made no difference (66 = 66 over 248M rows) — OTel SDKs omit attributes rather than emitting empty strings — but it is a real semantic change and is in the changeset.

Every other gate preserves its exact previous semantics.

## Deliberately unchanged

- **Value expressions** (`model`, token counts, `costUsd`, `ttftMs`, …) still read subscripts — they need the values. They remain subject to the planning fan-out, which the client-level setting in ClickHouse#3102 covers.
- **`isToolSpan`'s `openinference.span.kind = 'TOOL'`** stays an equality: it discriminates one span kind from others, and the query builder rewrites equality onto the attribute-items index.
- **`hasTtft`** keeps `> 0` — it gates a p50/p95 and zero-valued rows would drag the percentiles down.
- **JSON attribute columns** are untouched. Their paths are real subcolumns, there is no key index to prune with, and a presence term would only duplicate reads — so the JSON branch emits the original expression, pinned by `toBe` assertions.
- **`buildScopeCondition`'s `coalesce(...) = 'value'`.** The indexable form is `has(SpanAttributeItems, 'key=value')`, but the items column name lives in `renderChartConfig`'s `textIndexInfoLookup` and isn't reachable from the app package. Follow-up.

## Caveat: materialized columns

`fastifySQL` rewrites a `Col['key']` subscript onto a materialized column when an operator has created one, and it matches on the subscript AST node — so it cannot see `mapContains`. Such tables never had the planning problem either, since a rewritten subscript is no longer a subcolumn reference. This trades that rewrite for skip-index pruning, which is the better deal only where those columns don't exist (the default, and what the numbers above were measured on).

Teaching `fastifySQL` to match the `mapContains` shape would fix this and benefit queryParser's existing `indexHint(mapContains(...))` emissions too, but it changes shared query planning for every source. Not in this PR.

## How to test on Vercel preview

**Preview route:** `/llm`

1. Open `/llm`; confirm the Overview tab renders tiles with data, and that values match what the page showed before this change.
2. Latency tab: "Time to First Token" still plots median and p95.
3. Sessions tab: the session dropdown populates and selecting one filters every tile.
4. **User filter**: pick a user from the new dropdown and confirm every tab rescopes, including the Errors tab's log rows. Copy the URL, open it in a new tab, and confirm the dropdown still shows the selected user rather than "All users".
5. Move the time picker to a window with no data for the selected user; the dropdown must keep showing that user (with a clear "×") rather than reading "All users" over empty charts.

## Verification

- `yarn ci:unit` (app): 233 suites, 3,827 tests, pass
- `yarn ci:unit` (common-utils): 36 suites, 2,526 tests, pass
- `make ci-lint`: 0 errors · `tsc --noEmit` clean · `knip` clean
- Staging `EXPLAIN` A/B above; result equivalence confirmed over 7 days of data

## Notes for reviewers

- **The staging numbers were taken on the bare `mapContains` shape**, before review moved most gates to `indexHint(presence) AND value`. `indexHint` feeds the same expression to index analysis, and the `isLLMSpan` headline (36s → 7ms) is directly valid since that gate is still bare presence — but the post-review shape was not re-measured on staging. It is covered by a `renderChartConfig` test asserting the kv-items rewrite still fires through an `indexHint`-bearing condition.
- Staging still runs the **bloom-filter** `idx_span_attr_key`, since ClickHouse#3096 isn't deployed there. `mapContains` is served by both bloom-filter and text indexes on `mapKeys`, so the pruning result holds either way — but the measurements are from the bloom-filter index.
- The two `EXPLAIN indexes = 1` runs report different part counts (16 vs 19) because ingest continued between them; it doesn't affect the granule comparison.
- Two changesets: `patch` for the perf fix, `minor` for the user filter.

## References

- Related: [HDX-5348](https://linear.app/clickhouse/issue/HDX-5348/ch-263-allow-calculating-subcolumns-sizes-for-merge-tree-reading-makes) — the 26.3 planning regression; ClickHouse#3102 handles it at the client level
- Builds on: ClickHouse#3096 (trace schema text indexes)
…il to load (ClickHouse#3105)

## Summary

Follow-up to ClickHouse#3104, addressing a detail raised in review there that landed after the merge.

ClickHouse#3104 fixed the scope dropdowns (session, user) falling back to their placeholder when the applied value wasn't in the fetched options — the control read "All users" while every chart stayed filtered. Pinning the value made it visible, but the control is still `disabled` whenever the distinct-value query is loading or has failed:

```tsx
disabled={isLoading || isError}
```

So with a filter applied and a failing query, the user can see the scope but can't remove it — and `isError` doesn't clear on its own, so that state is permanent. Mantine also only renders the `clearable` × on an enabled input, so there is no escape from the control itself.

Now only disabled when nothing is applied:

```tsx
disabled={(isLoading || isError) && !value}
```

Loading with no filter still disables, which is the case the guard was for.

## Tests

Two cases added to `DistinctValueSelect.test.tsx`:

- applied value + failing query → input is enabled and still shows the value
- loading with nothing applied → input is disabled (pins the behaviour that's kept)

The first fails against the old `disabled={isLoading || isError}`.

## Verification

- `yarn ci:unit` (app): 233 suites, 3,829 tests, pass
- `make ci-lint`: 0 errors · `tsc --noEmit` clean · `knip` clean

## References

- Follows: ClickHouse#3104
## Summary

**Next.js goes 16.2.11 to 16.3.4 in `packages/app`**, picking up what shipped in 16.3.3 and 16.3.4.

No application code moved. `packages/app` is Pages Router only, so the 16.3.0 App Router deprecations (edge runtime, `experimental.useCache`/`dynamicIO`, `partialFallbacks`) never come into play, and a grep confirmed none of those flags were set anywhere.

Two ESLint packages ride along: `@next/eslint-plugin-next` and `eslint-config-next`, both 16.0.10 to 16.3.4. Both sat two minors behind the framework. Lint attributes zero warnings to `@next/next` rules, so the bump costs nothing.

**Expect a big `yarn.lock` diff.** Next's own pins moved with it and nothing else did: `sharp` 0.34.5 to 0.35.4 plus its whole `@img/*` platform-binary set, `postcss` 8.4.31 to 8.5.23, `@swc/helpers` 0.5.15 to 0.5.23.

`packages/app/AGENTS.md` is new, and Next wrote it. 16.3 added a `next dev` step that drops an agent-rules block into the project directory whenever it detects an AI coding agent. Committing the file satisfies its check, which kills the recurring uncommitted change and stops it creating a `packages/app/CLAUDE.md` alongside.

Everything ran green. `make ci-lint` (0 errors, 559 warnings against a 564 cap), `yarn ci:unit` (3829 tests), the production Webpack build, the ClickStack static export, a Turbopack dev boot, and the navigation E2E suite (10 passed).

### How to test on Vercel preview

**Preview routes:** /search, /dashboards, /chart

**Steps:**

1. Open /search and verify the page renders with the search input and a results table.
2. Open /dashboards and verify the dashboard list renders.
3. Open /chart and verify the chart builder renders.

### References

- Linear Issue:
- Related PRs:
## Summary

Disabled sources still triggered ClickHouse queries when a page loaded. The reported symptom was a `SELECT name, value FROM system.settings FORMAT JSON` query firing for a source that was turned off.

The root cause is that several load-time helpers build their query inputs from the full source list (or every source on a connection) without checking `source.disabled`. The `system.settings` fetch is a per-connection side effect of `processClickhouseSettings`, so any metadata/field/value query issued for a disabled source's connection surfaces as that observed query.

This PR excludes disabled sources from the load-time builders that fan out queries across sources:

- **`RawSqlChartEditor`** — the SQL editor autocomplete built `tableConnections` from *every* source on the connection, then ran `getAllFields`/`getColumns` per table on mount.
- **`DBDashboardPage`** — the dashboard WHERE-bar autocomplete built `tableConnections` from tile sources without skipping disabled ones.
- **`useQueriedDashboardFilterValues`** — dashboard filter dropdowns ran a key-values lookup per filter source, keeping filters whose source is disabled.
- **`OnboardingChecklist`** — the "has data" probe OR-combined every source on the first connection into a `system.tables` query.

Disabled sources produce no user-selectable autocomplete/filter values anyway, so filtering them out has no visible downside; it only removes wasted queries. Enabled sources continue to load their fields/settings normally.

Out of scope: a dashboard *tile* that directly references a source which was later disabled still runs its chart query. Blanking such a tile is a product/UX decision (it should probably show a "source disabled" state), so it's left for a follow-up.

### Screenshots or video

N/A — non-UI change (removes background queries only; no visual change).

### How to test on Vercel preview

N/A — non-UI change.

### References

- Linear Issue: HDX-5355
- Related PRs:



Linear Issue: [HDX-5355](https://linear.app/clickhouse/issue/HDX-5355/prevent-queries-for-disabled-sources-on-load)

<div><a href="https://cursor.com/agents/bc-70a0990f-9561-4816-a0fb-957e7df78166?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-70a0990f-9561-4816-a0fb-957e7df78166&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>



Co-authored-by: Cursor Agent <199161495+cursoragent@users.noreply.github.com>
…on on CH 26.3+ (ClickHouse#3102)

## Summary

ClickHouse 26.3 flipped `allow_calculating_subcolumns_sizes_for_merge_tree_reading` to on by default. `optimizePrewhere` then calls `getColumnSizes` with subcolumn sizes enabled, which walks every **(active part × referenced map key)** to read per-part sizes. On SharedMergeTree each of those is an S3 GET. It happens during **planning**, before a single row is read, and neither `max_execution_time` nor client cancellation interrupts it.

Measured on a 26.6 SharedMergeTree cluster with 162 active parts. `EXPLAIN PLAN` only, so 0 rows read — this is pure planning cost:

| distinct map keys | setting | duration | `S3GetObject` | outcome |
| :--- | :--- | :--- | :--- | :--- |
| 1 | `0` | 5 ms | 0 | ok |
| 1 | `1` | 4,670 ms | 148 | ok |
| 10 | `1` | 36,111 ms | 1,480 | `TIMEOUT_EXCEEDED` as `ExceptionBeforeStart` — overran a 30s `max_execution_time` by 6s |

Cost is linear in referenced keys, at ~one GET per active part each (148/key here). The LLM dashboard reads ~64 keys per tile. On a 645-part cluster running 26.4 that worked out to 64k GETs and **27 minutes of planning for zero rows** — and because 26.4 still held the `DataParts` lock across the loop, a queued writer starved every other reader on the replica for ~25 min. One page load froze it.

This sends the setting as `0`.

### Why in the shared client

The fix goes in `processClickhouseSettings` (`packages/common-utils/src/clickhouse/index.ts`) rather than at each call site, for two reasons:

- **One change covers every caller** — charts, search, alerts, the API, and MCP all go through this method. Patching `useChartConfig` alone would leave alert evaluation exposed, and it runs unattended.
- **`applySettingIfAvailable` is already the version guard.** It only emits settings present in that connection's `system.settings` (cached per `connectionId`), so pre-26.3 servers never receive it and can't fail on an unknown setting. No version parsing needed.

Caller-supplied `clickhouse_settings` are still spread last, so anyone who explicitly wants the calculation on can override.

The sizes are only used to *reorder* PREWHERE conditions. The `= 0` path approximates each subcolumn with its parent column size, which is exactly what every version before 26.3 did.

### Also included

`EfficiencyCharts` and `LatencyCharts` bound the ~70 KiB model-price cost expression as a WITH alias and never selected it. Both now pass `withCostAlias: false`, matching what `ErrorsTab` / `LatencyTab` / `LLMEmptyStateBanner` already do. Smaller queries, less parse time, no behavior change.

### How to test on Vercel preview

N/A — non-UI change. No visual difference; the change is in the settings sent alongside each query.

To verify against a ≥26.3 server, run any dashboard query and check it in `system.query_log`:

```sql
SELECT Settings['allow_calculating_subcolumns_sizes_for_merge_tree_reading']
FROM system.query_log
WHERE event_date = today() AND type != 'QueryStart'
ORDER BY event_time DESC LIMIT 5;
```

Before this change the column is empty; after, it reads `0`.

### Verification

- `make ci-unit` — 5 projects, 7,756 tests, exit 0
- `make ci-lint` — 5 projects, 0 errors, exit 0
- `tsc --noEmit` clean in `common-utils` and `app`
- New unit coverage: the setting is applied when the server advertises it, and omitted when it does not (the pre-26.3 path)

### Notes for reviewers

- **Not verified end-to-end against a real ≥26.3 SharedMergeTree cluster through the app.** The staging cluster I probed already has `compatibility = '26.2'` set, which independently pins this setting to `0`, so a live run there would only prove the setting is transmitted, not that it changes anything. The measurements above come from forcing the flag on per-query via `SETTINGS` on that same cluster.
- **This does not help queries already stuck in planning for other reasons** — `max_execution_time` still doesn't bound the planner. It removes this particular fan-out, not the general exposure.
- Follow-ups tracked on the ticket: setting the profile on the affected cluster, auditing other CHC ClickStack services on ≥26.3, an upstream ClickHouse issue, and materialized `__hdx_llm_*` columns for the execution-time (rather than planning-time) cost.

### References

- Linear Issue: [HDX-5348](https://linear.app/clickhouse/issue/HDX-5348/ch-263-allow-calculating-subcolumns-sizes-for-merge-tree-reading-makes)
- Related PRs: hyperdx-ee#3261 (alert `max_execution_time`) — its "does not bound planning" caveat is this issue
> **Stacked on ClickHouse#2747.** Base is `claude/deterministic-metric-name-listing`, so this diff is only the streaming layer. Merge ClickHouse#2747 first.

## Problem

The metric name select waited on a `groupUniqArray` aggregation over the data before showing anything. On a source reporting ~4,900 gauge metrics that is ~770ms of empty dropdown, paid on every chart editor mount — and the control is mounted per series, on every dashboard tile editor.

## Fix

Read `MetricName` from the table's sparse primary index via the `mergeTreeIndex` table function instead of scanning the data: one row per granule mark. Measured on a 121M-row `otel_metrics_gauge` with 4,914 gauge names:

| | cold latency | names returned |
|---|---|---|
| index read | **7ms** | 1,986 |
| exhaustive `GROUP BY` | 767ms | 4,914 |

`DISTINCT` is a streaming transform in ClickHouse, so results also arrive progressively rather than in one block — first options on screen ~30ms after the query starts, and a small spinner replaces the chevron while more land. `ORDER BY` is deliberately omitted, since the sort would have to complete before the first row; the client sorts.

## The index returns a subset, and that is the design

The index only records the column's value at each granule boundary, so a metric confined to one granule never appears. On the test dataset that is 1,986 of 4,914 names — and the split is not arbitrary:

| | metric names | median datapoints each |
|---|---|---|
| visible in index | 1,991 | **32,060** |
| invisible | 2,923 | **14** |

The hidden ~2,900 are almost entirely metrics with a dozen datapoints. So browsing shows the metrics that actually carry data, and **typing switches to the exhaustive relevance-ranked search from ClickHouse#2747**, which reaches anything the index omitted. The placeholder reads `Search metrics...` to invite that. Verified end-to-end: a metric absent from the index is found by typing its name, ranked first.

Browsing also falls back to that same exhaustive query when the index cannot be read at all — a server older than 24.2, a Distributed or non-MergeTree metric table, or a primary key without `MetricName` — so no deployment loses the picker.

## Two behaviours worth reviewing

**The options never blank mid-keystroke.** `keepPreviousData` serves the *previous* pattern's page when the search key changes, and results for `http` cannot match a search for `billing` — the client-side filter reduces them to nothing and the dropdown flashes empty for ~700ms. This ignores placeholder pages (`isPlaceholderData`) and holds the unfiltered browse list instead, which still filters to relevant matches immediately. Measured before/after with a `MutationObserver` on the option list.

**Render cap raised 100 → 500** to match `DEFAULT_METRIC_NAMES_LIMIT`, so a search not reported as `truncated` is fully renderable. Mantine renders options as plain DOM, so this costs ~150ms of keystroke blocking at ~4.9k names that a cap of 100 avoided — an accepted trade, not an oversight.

## Known limitation, not addressed here

`getMetricOptions` concatenates the four kind lists gauge-first, so kind rather than relevance decides what survives the render cap. Searching `http` on the test dataset returns 0 of 26 matching Sum metrics behind 325 gauge matches. This predates both PRs (called out in ClickHouse#2747 as tracked separately) and the fix is a relevance-ordered merge across kinds; raising the cap does **not** fix it, since gauge alone exceeds 500 while browsing.

## Testing

- `Metadata.streamDistinctIndexValues`: 13 unit tests over a faked `BaseResultSet` — chunk-per-chunk yielding, the emitted SQL, parts pruning, and each guard (old server, pointer table, non-MergeTree engine, column outside the primary key, including a column that only appears nested in a key expression).
- `useStreamingQuery`: 5 tests — partial publication before completion, throttle-away, error discards partials, disabled, empty stream.
- `useMetricNames`: 10 tests — per-kind streaming and sorting, mode switch, the hold, the stale-placeholder guard, the fallback.
- `MetricNameSelectSearch`: ClickHouse#2747's 11 cases retargeted to the two-mode behaviour, all passing.
- E2E (`metric-name-streaming.spec.ts`): browses from the index, then finds an index-invisible metric by typing. Asserts the seed still *has* such a metric, so the test cannot pass vacuously.
- `make ci-lint` clean, `make ci-unit` 3400 passed. Verified by hand against a 121M-row local instance.

## Notes

`streamToAsyncIterator` moves from `packages/app/src/sessions.ts` (marked `TO BE DEPRECATED`) into `common-utils` beside the ClickHouse client. `Metadata.streamDistinctIndexValues` is generic over table and column, so `ServiceName` and other primary-key columns can be listed the same way.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Fixes ClickHouse#3059.

## Why
A saved search scoped through the filter bar evaluated correctly, but the sample events rendered into the notification came from an unfiltered query, so alerts showed unrelated log lines as evidence next to a correct count.

## What changed
The sample fetch in renderAlertTemplate hand-built its chart config from where/whereLanguage only. It now also passes savedSearch.filters, the same input the evaluation path feeds buildSearchChartConfig, so renderChartConfig narrows both queries to the same row set. TILE and INLINE paths are untouched. Includes a changeset (patch, @hyperdx/api).

## Tests
- New integration case in renderAlertTemplate.int.test.ts renders a saved-search alert whose search carries a pinned filter and asserts the generated sample SQL contains the filter condition. It fails on unpatched main and passes with the fix.
- Targeted run against local ClickHouse/Mongo services: 1 passed.
- ESLint on touched files: 0 errors (8 pre-existing warnings elsewhere in the files); Prettier clean.

Co-authored-by: Warren Lee <5959690+wrn14897@users.noreply.github.com>
Co-authored-by: Tom Alexander <3245235+teeohhem@users.noreply.github.com>
## Summary

Backfills alert `name` and `tags` from the referenced saved search, dashboard tile, or inline chart config.

- Adds `runStartupMigrations()` called from `Server.start()`. Idempotent and re-runs on every start, alerts with an existing name or tags are skipped.
- Adds optional `tags` field to the Alert model.
- Since `alert.name` is also used as the notification title template, a name that isn't valid Handlebars now renders as is to avoid breaking the notification.

Tested with `make dev-int FILE=src/__tests__/migrations.int.test.ts` and `FILE=renderAlertTemplate.int.test.ts`, plus unit tests for the name/tags derivation.

### References

- Linear Issue: HDX-5149
…use#3084)

## What

Adds a second phase to the sidebar onboarding checklist. After the existing setup steps (connect ClickHouse, create sources, add data) complete, a product-usage phase tracks four "getting started" milestones per user:

- **Explore your data** — run a search with a filter or query condition
- **Build a dashboard** — add a chart tile to a dashboard
- **Set up an alert** — create or edit an alert
- **Connect the MCP server** — make a successful MCP tool call

Each task links to where to do it, the card can be dismissed, and a brief celebration shows when the final task is completed in-session.

## Why

The existing checklist stops once a team is technically set up. New users still need a nudge toward the actions that deliver value (searching, charting, alerting, querying via an agent). This extends the same surface to cover first real usage without adding a separate onboarding UI.

## How it works

- **Single source of truth**: the task registry (`ONBOARDING_TASK_IDS`) lives in `common-utils`. It's typed so adding a task is a compile error until both the API validation enum and the frontend UI (copy + link) are updated — a new task can't be silently untracked.
- **Surface-agnostic completion**: alert and dashboard tasks complete whether the action came from the UI, external REST API v2, or an MCP tool (all authenticate with the user's personal access key). Recorded server-side and fire-and-forget, so onboarding bookkeeping never blocks or fails the triggering write.
- **"Dashboard" means a chart, not a shell**: the dashboard task completes only once a dashboard has at least one tile — including tiles added to a temporary (unsaved, URL-state) dashboard, which the frontend records directly since it never touches the backend.
- **Explore data**: completes on any non-trivial search (a non-empty where clause in Lucene or SQL, or an applied filter); a blank default search doesn't count.
- **MCP**: recorded in the tool-tracing chokepoint — a successful tool call is the only reliable signal the user exercised the server.
- **Cache hygiene**: after a UI action the frontend patches only `onboardingData` in the cached `me` object — no `me` refetch — so the many `useMe` consumers (metadata, ClickHouse settings, nav) are untouched.
- **Derived "done"**: the checklist's completed state is derived from whether every current task is complete rather than a persisted flag, so adding or changing a task later automatically reopens the checklist for users who finished the old set. The persisted `isDismissed` is only for the manual X (opting out early).
- Read-tolerant / write-strict schema: the read path drops persisted task ids no longer in `ONBOARDING_TASK_IDS` (so removing a task can't 500 `GET /me`), while the write boundary stays a strict enum. `onboardingData` is defaulted for users created before the field existed.

## Demo

https://github.com/user-attachments/assets/0e693c55-0f54-4177-86d8-239ac6403fca

## Persistence

New optional `user.onboardingData` subdocument (`completedTasks`, `isDismissed`). Two new routes: `POST /me/onboarding/task` and `PATCH /me/onboarding/dismiss`.

## Testing

- `make ci-lint` — lint + tsc + styles + escape-hatch ratchet: green
- `make ci-unit` scope — app (onboarding suites + DBSearchPage + dashboard, 274 tests), common-utils (2424), api (825): all pass
- Integration tests updated (`me.int.test.ts`, external `dashboards.int.test.ts`); they require the Docker stack (`make dev-int`) and were not run in this session.
…House#3125)

## Why

Service map nodes were always some shade of red, and you couldn't tell a service with zero errors from one with a trace of them.

Two causes, both in `getNodeColors`:

1. **The ramp was max-normalized.** `intensity = value / max` meant the darkest node was always whichever service had the highest error rate *on the current graph* — whether that was 0.3% or 60%. A perfectly healthy map painted its best-of-a-good-bunch service full dark red.
2. **Zero sat at the bottom of the red ramp,** rendering `hsl(0 35% 90%)` — a pale pink. A service at 5% of the graph max landed at `hsl(0 37% 88%)`, a 2-point lightness difference on a 32px circle. Not perceptible.

Worst case: when no service has errors at all, `metricMax.errorRate === 0`, so every node renders identically pale pink — indistinguishable from "everyone has a trace of errors."

This is out of step with how other tools handle it. Datadog and New Relic leave healthy nodes neutral and only color on an absolute threshold breach (New Relic explicitly won't draw green for an operational node). AWS X-Ray and Grafana's Tempo service graph use a proportional donut. Nobody asks users to read error rate off a lightness ramp.

## What changed

Error rate now maps to four **absolute** states:

| Error rate | Fill |
|---|---|
| `0` | `hsl(220 8% 82%)` — neutral cool grey |
| `0 – 1%` | `hsl(0 48% 73%)` |
| `1 – 5%` | `hsl(0 61% 56%)` |
| `≥ 5%` | `hsl(0 72% 42%)` |

- The neutral is a **hue shift, not desaturation**. At 32px a cool grey reads as a different kind of thing; a washed-out red just reads as a little red.
- The three red stops come from the existing ramp, so no new hexes enter the palette.
- 5% is the threshold `ServiceMapTooltip` already used for its alarm color, now exported as `ERROR_RATE_HIGH` and shared, so the fill and the hover text can't drift apart.
- **Latency and throughput are unchanged** — they keep the max-normalized ramp. Neither has a universal threshold to anchor to.
- The legend gradient emits hard stops for error rate (four discrete steps rather than a blend, since a continuous bar would imply precision the bucketing doesn't have), and labels the top of the scale `≥5%` instead of the observed max.

No changes to `ServiceMapNode`, `ServiceMap.tsx`, or the SCSS — the node already just consumes `{ backgroundColor, borderColor }`.

## Trade-off

With hard buckets a service at 4.9% and one at 60% paint identically. A proportional donut would keep that distinction, but it's a much larger change (and a four-way success/4xx/5xx/throttle split isn't available to us — the source schema only has `statusCodeExpression`, and OTel's `StatusCode` is just `Ok`/`Error`/`Unset`). The tooltip shows the exact percentage on hover, so the detail is recoverable.

Thresholds are hardcoded as exported constants. A service on a 99.9% SLO and a batch job have different notions of "elevated" — if that comes up, per-source overrides are the natural extension.

## Testing

`packages/app/src/components/ServiceMap/__tests__/utils.test.ts` — existing ramp tests re-pointed at `throughput` (still max-normalized), plus a new block covering the absolute buckets: zero renders neutral, buckets ignore `max`, thresholds are inclusive lower bounds, and the neutral state still draws a darker border.

Full app suite green (223 suites / 3664 tests), `make ci-lint` clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…House#3115)

## Summary

This PR fixes a few bugs causing failures when opening the row side panel, when not using the _block_number/ _block_offset based lookup.

To reproduce, disable _block_number/ _block_offset based lookup with `ALTER TABLE <source_table> MODIFY SETTING enable_block_offset_column = 0, enable_block_number_column = 0;`

### Screenshots or video

#### Select containing hyphenated column:

Before

<img width="2313" height="839" alt="Screenshot 2026-09-11 at 2 34 39 PM" src="https://github.com/user-attachments/assets/8f44e47e-4866-4da5-89bc-230b6f943f0d" />
<img width="1707" height="774" alt="Screenshot 2026-09-11 at 2 35 05 PM" src="https://github.com/user-attachments/assets/5b8dff75-1b5f-4318-b323-bcd71cff6599" />

After

<img width="2312" height="928" alt="Screenshot 2026-09-11 at 2 30 30 PM" src="https://github.com/user-attachments/assets/1521877a-b661-4e4d-a81f-3657c4b3b593" />

#### Select containing a backtick-quoted alias, with other aliases

Before

<img width="1714" height="861" alt="Screenshot 2026-09-11 at 2 33 55 PM" src="https://github.com/user-attachments/assets/d3e96c2d-cb75-46eb-916c-2be36797a64f" />

After

<img width="2310" height="1193" alt="Screenshot 2026-09-11 at 2 32 11 PM" src="https://github.com/user-attachments/assets/789c4237-9671-476a-849c-26e064cbc65d" />

### How to test locally

Add a hyphenated column to your logs table

```
alter table otel_logs add column `test-hyphen` String
```

Disable the block_number filtering:

```
alter table otel_logs modify setting enable_block_offset_column = 0, enable_block_number_column = 0;
```

### References



- Linear Issue: Closes HDX-5363
- Related PRs:
karl-power and others added 16 commits October 23, 2026 15:03
…se#3450)

## Summary

This PR moves some external API source conversion functions into common-utils, so that they can be re-used in non-api contexts. This is a pure refactor, no behavior changes are expected.
Bumps [hono](https://github.com/honojs/hono) from 4.13.9 to 4.13.13.
e notes</summary>
<p><em>Sourced from <a href="https://github.com/honojs/hono/releases">hono's releases</a>.</em></p>
<blockquote>
<h2>v4.13.13</h2>
<h2>Mount Middleware</h2>
<p><code>app.mount()</code> is now available as the Mount Middleware, <code>hono/mount</code>. It is just a handler, so you register it with <code>app.all()</code>:</p>
<pre lang="ts"><code>import { Router as IttyRouter } from 'itty-router'
import { Hono } from 'hono'
import { mount } from 'hono/mount'
<p>const ittyRouter = IttyRouter()<br />
ittyRouter.get('/hello', () =&gt; new Response('Hello from itty-router'))</p>
<p>const app = new Hono()<br />
app.all('/itty-router/*', mount(ittyRouter.handle))<br />
</code></pre></p>
<p><code>app.mount()</code> still works in v4 but is deprecated and will be removed in v5. Migrating is a one-line change:</p>
<pre lang="diff"><code>- app.mount('/itty-router', ittyRouter.handle)
+ app.all('/itty-router/*', mount(ittyRouter.handle))
</code></pre>
<h2>What's Changed</h2>
<ul>
<li>test(client): simulate network error for undefined route in parseResponse test in <a href="https://redirect.github.com/honojs/hono/pull/5439">honojs/hono#5439</a></li>
<li>docs(request): fix jsdoc comments for some getters in <a href="https://redirect.github.com/honojs/hono/pull/5445">honojs/hono#5445</a></li>
<li>fix(jsx): allow JSXNode function component results in <a href="https://redirect.github.com/honojs/hono/pull/5476">honojs/hono#5476</a></li>
<li>feat(mount): introduce Mount Middleware and deprecate <code>app.mount</code> in <a href="https://redirect.github.com/honojs/hono/pull/5221">honojs/hono#5221</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/honojs/hono/compare/v4.13.12...v4.13.13">https://github.com/honojs/hono/compare/v4.13.12...v4.13.13</a></p>
<h2>v4.13.12</h2>
<h2>What's Changed</h2>
<ul>
<li>fix(build): keep internal types private in bundled d.ts and avoid a self-referencing JSX.IntrinsicElements in <a href="https://redirect.github.com/honojs/hono/pull/5485">honojs/hono#5485</a></li>
<li>test(build): type-check the bundled declarations from a consumer project in <a href="https://redirect.github.com/honojs/hono/pull/5486">honojs/hono#5486</a></li>
<li>fix(etag): correctly match mixed-case header name in retainedHeader option in <a href="https://redirect.github.com/honojs/hono/pull/5475">honojs/hono#5475</a></li>
<li>fix(jsx): add px to numeric gridGap, gridRowGap and gridColumnGap in <a href="https://redirect.github.com/honojs/hono/pull/5487">honojs/hono#5487</a></li>
<li>fix(combine): return a Response from a short-circuiting middleware in some() in <a href="https://redirect.github.com/honojs/hono/pull/5391">honojs/hono#5391</a></li>
<li>chore(deps): upgrade vite-plus to 1.0.0 in <a href="https://redirect.github.com/honojs/hono/pull/5464">honojs/hono#5464</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/honojs/hono/compare/v4.13.11...v4.13.12">https://github.com/honojs/hono/compare/v4.13.11...v4.13.12</a></p>
<h2>v4.13.11</h2>
<h2>Security fixes</h2>
<h3><code>serveStatic</code> decodes the request path a second time, leading to bypass of middleware on static paths</h3>
<p>Affects: <code>hono/serve-static</code> and the adapters built on it (<code>hono/bun</code>, <code>hono/deno</code>, <code>hono/cloudflare-workers</code>, <code>@hono/bun</code>, <code>@hono/deno</code>, <code>@hono/cloudflare-workers</code>). Fixes <code>serveStatic</code> decoding an already-decoded path, where a crafted request could be routed as one path and served as another, skipping middleware mounted on a static prefix. GHSA-5r4p-p66f-jhc7</p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/honojs/hono/commit/08a023cbfde55b434fb0fb30fae35d42e4bf20aa"><code>08a023c</code></a> 4.13.13</li>
<li><a href="https://github.com/honojs/hono/commit/ae595de8ae05ea548560da93140ee1d53e5a8202"><code>ae595de</code></a> feat(mount): introduce Mount Middleware and deprecate <code>app.mount</code> (<a href="https://redirect.github.com/honojs/hono/issues/5221">#5221</a>)</li>
<li><a href="https://github.com/honojs/hono/commit/f23b146afcec63606144cde50b5fbd360dd60238"><code>f23b146</code></a> fix(jsx): allow JSXNode function component results (<a href="https://redirect.github.com/honojs/hono/issues/5476">#5476</a>)</li>
<li><a href="https://github.com/honojs/hono/commit/6d73a74f9757ed65d6103c0e8375097fa20873c4"><code>6d73a74</code></a> docs(request): fix jsdoc comments for some getters (<a href="https://redirect.github.com/honojs/hono/issues/5445">#5445</a>)</li>
<li><a href="https://github.com/honojs/hono/commit/deff529656cff775b35956f7f9071388d4eb55ae"><code>deff529</code></a> test(client): simulate network error for undefined route in parseResponse tes...</li>
<li><a href="https://github.com/honojs/hono/commit/6abd35b0a5f35f67b6417627d5b0a6c2d266ac04"><code>6abd35b</code></a> 4.13.12</li>
<li><a href="https://github.com/honojs/hono/commit/95eb860473d26bd303a2a29716e61a77c8c5e5f5"><code>95eb860</code></a> chore(deps): upgrade vite-plus to 1.0.0 (<a href="https://redirect.github.com/honojs/hono/issues/5464">#5464</a>)</li>
<li><a href="https://github.com/honojs/hono/commit/afb2068c1c53a530528ac5f237e245793dd03c07"><code>afb2068</code></a> fix(combine): return a Response from a short-circuiting middleware in some() ...</li>
<li><a href="https://github.com/honojs/hono/commit/e5bb2062a3ddd645e3a16d1300bd76d6c9af33a5"><code>e5bb206</code></a> fix(jsx): add px to numeric gridGap, gridRowGap and gridColumnGap (<a href="https://redirect.github.com/honojs/hono/issues/5487">#5487</a>)</li>
<li><a href="https://github.com/honojs/hono/commit/c3053ccf14f1c4c70a0c2888eed6121b9f6c8f95"><code>c3053cc</code></a> fix(etag): correctly match mixed-case header name in retainedHeader option (#...</li>
<li>Additional commits viewable in <a href="https://github.com/honojs/hono/compare/v4.13.9...v4.13.13">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=hono&package-manager=npm_and_yarn&previous-version=4.13.9&new-version=4.13.13)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/hyperdxio/hyperdx/network/alerts).

</details>
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.


# Releases
## @hyperdx/cli@0.8.0

### Minor Changes

- 0b1e078: Add dashboard and saved-search authoring commands to the CLI:

  - `hdx dashboards list` (default subcommand) gains `--query` name filtering and `--format table|json|csv`
  - `hdx dashboards export --id <id-or-name>` prints a dashboard definition as JSON, round-trip compatible with import
  - `hdx dashboards create` accepts either a full definition (`--file <json>`) or inline charts (`--name` + repeatable `--chart` / `--chart-file`, with automatic grid layout), plus `--if-not-exists`
  - `hdx dashboards import --file <json>` recreates an exported dashboard, with `--if-not-exists` and `--name-override`
  - `hdx saved-searches` command group: `list` and `create` (`--name/--source/--where/--where-language/--select/--order-by/--tags`)

  Definitions are validated locally against the shared schema before being sent, and missing tile ids are generated automatically. Also fixes `getSavedSearches()` to call the correct `/saved-search` API path (the previous `/saved-searches` path always returned 404).

### Patch Changes

- 5253b86: fix: Stop abbreviating values between 500 and 999 as "0.5k"/"0.8k" on chart axes and in large number format
## @hyperdx/api@2.42.0

### Minor Changes

- 9c2c703: feat: Support PromQL dashboard tiles in the external API

  `/api/v2/dashboards` now returns PromQL tiles and accepts them on create and update, as `configType: "promql"` with a `sourceId` and a list of `expressions`. Previously PromQL tiles were left out of responses, so saving a dashboard back through the API deleted them. MCP dashboard tools still hide PromQL tiles until they support editing them.

- eb72cfa: feat(mcp): add `clickstack_service_health`, a one-call per-service health report for a trace source (throughput, error rate, p50/p95/p99, top error groups), compared with yesterday and ranked worst first
- 95aef70: Add an optional `eventNameExpression` to log sources, pointing at the
  OpenTelemetry log EventName. Source auto-detection sets it when an OTel log
  table has a string `EventName` column. The field is stored and editable only:
  nothing reads it yet. HDX-5787
- bc47e48: Add an optional `severityNumberExpression` to log sources, pointing at the
  numeric OpenTelemetry SeverityNumber (1-24). Source auto-detection sets it when
  an OTel log table has an integer `SeverityNumber` column. The field is stored
  and editable only: log levels still come from the severity text.
- cfa865b: feat: expose group-by values to webhook body templates as `{{var.<column>}}`, and keep them on resolve notifications

### Patch Changes

- 9891a79: feat: Add ad hoc dashboard filter schemas and types
- 27cccf7: fix: Reject creating or updating PromQL sources through the external API when PromQL is not enabled

  `POST` and `PUT /api/v2/sources` now return a 400 for `kind: "promql"` unless `ENABLE_PROMQL` is set, since PromQL sources cannot be queried without it.

- 8d064df: fix: Reject external API PromQL tiles that reference a non-PromQL source, and keep PromQL tiles when an MCP agent updates a dashboard

  `/api/v2/dashboards` now returns a 400 when a PromQL tile's `sourceId` is not a PromQL source. MCP `clickstack_save_dashboard` updates no longer delete the PromQL tiles that MCP hides from agents.

- 0000fa4: feat: Add a line interpolation display setting (smooth, linear, step)
- ee9f84b: feat(mcp): record the response size of each MCP tool call
- 24d324c: fix(mcp): store whereLanguage as lucene when clickstack_save_saved_search creates a search without one, so its alerts no longer parse a Lucene filter as SQL
- 748315d: feat(mcp): advertise a title, description, website URL, and SVG icon in the MCP server's initialize response
- cdfae45: fix(mcp): `clickstack_sql` now binds the bucket-size parameters its description
  advertises. `{intervalSeconds:Int64}`, `{intervalMilliseconds:Int64}`,
  `$__interval_s`, `$__timeInterval` and `$__timeInterval_ms` used to fail with
  "Substitution intervalSeconds is not set", including the tool's own time-series
  example.
- b00d410: feat(mcp): `clickstack_sql` accepts an optional `sourceId`, so `$__sourceTable`
  expands to that source's table. Unknown sources, other teams' sources, and a
  source on a different connection than `connectionId` are rejected, as is
  `$__sourceTable` without a `sourceId`.
- a06b17f: fix(mcp): stop `clickstack_sql` callers from guessing table and timestamp column
  names. `clickstack_list_sources` now returns each source's database-qualified
  `table` (and `database` for metric sources), the `sql` examples use `<table>`
  and `<timestampColumn>` placeholders instead of `otel_logs` and `TimestampTime`,
  and unknown-table errors from `clickstack_sql` point at `clickstack_list_sources`.
- e494a46: feat(mcp): declare readOnlyHint, destructiveHint, idempotentHint, and openWorldHint on every MCP tool
- 03f9fd5: fix(mcp): describe every MCP tool input, drop descriptions that restate schema constraints, and keep tool descriptions under Claude Code's 2 KB limit
- 5cabc11: feat: Support distribution heatmaps calculated from promql data
- a4b6395: fix: render percentile aggregations (`p50`, `p90`, `p95`, `p99`) as `quantile`
  at the matching level. A chart config with `aggFn: 'p95'` rendered as the
  nonexistent ClickHouse function `p95If`. The AI assistant now returns
  percentiles with a level instead of a bare `quantile` (median), and v1 metric
  chart conversion keeps the percentile level.
- ca8e3fd: fix: support https for the PromQL remote-write endpoint and ClickHouse scraping

  `CLICKHOUSE_PROMETHEUS_METRICS_ENDPOINT` now accepts an `http://` or `https://` URL as well as a bare `host:port`. With `https://`, the collector's `prometheusremotewrite` exporter sends metrics over TLS and verifies the server certificate against the system CAs. Previously the exporter always used plain HTTP, so an HTTPS-only endpoint could not receive metrics. This applies to both standalone and OpAMP-managed collectors.

  A bare `host:port` keeps plain HTTP, so existing setups are unchanged. The value must not include a path (`/write` is still appended automatically); the collector now exits at startup with a clear error if it does, or if the scheme is anything other than `http` or `https`.

  The OpAMP-managed collector scraped the endpoint over plain HTTP regardless of its scheme. ClickHouse is now scraped in its own `clickhouse` job (previously the `otelcol` job) using the endpoint's scheme, with TLS verification on. The collector's own metrics endpoint stays on plain HTTP. A bare `host:port` or `http://` endpoint behaves as before. Scraped ClickHouse metrics now carry `job="clickhouse"` instead of `job="otelcol"`.

- c94d12c: fix: Resolve PromQL queries through the source instead of a caller-supplied connection and table

  The `/api/v1/prometheus/*` routes now take `sourceId` instead of `connectionId`, and read the connection and TimeSeries table from that PromQL source. Caller-supplied `database`/`table` params are ignored for ClickHouse and no longer forwarded to external Prometheus endpoints. Saved PromQL chart configs now require `source` and no longer persist `connection`, so provisioned dashboards with PromQL tiles must set `source`.

- be5304d: Forward the bytes consumed by the ClickHouse proxy's body parsers without
  changing JSON values, form fields or text encodings. Keep request framing in
  sync with inflated bytes and preserve unparsed multipart streams.
- af6e7f6: Add a description field to the tile editor. The description is now stored on the
  tile's chart config next to its name, and is kept for series tiles in the
  external Dashboards API.
- 02080f2: Add an optional tile description, accepted by the external Dashboards API and
  shown as an info tooltip next to the tile name.
- ce6654d: refactor: Store the time display setting as a time zone instead of a UTC switch, converting existing settings, and remove the `isUTC` option from `formatDate`
- 28168ae: fix: Floor the time filter start to the precision of a coarse timestamp column (DateTime, DateTime64 below ms) when a finer column also filters the range, so a sub-second start no longer drops the rest of that tick; make the external API v2 search `maxResults` cap configurable via `EXTERNAL_API_SEARCH_MAX_RESULTS` (defaults to 2000, the previous hardcoded value)
- ecddea2: fix: reject generic and incident.io webhook body templates that don't compile,
  or that call a helper delivery doesn't register (e.g. a typo for `eq`), with a
  400 on create, update, and test (internal API, External API v2, and MCP
  `clickstack_save_webhook`). Add `POST /api/v2/webhooks/validate` to check a
  webhook config without saving it, e.g. at Terraform plan time.
- Updated dependencies [9205823]
- Updated dependencies [3e9185d]
- Updated dependencies [180a1f1]
- Updated dependencies [a682ce1]
- Updated dependencies [9891a79]
- Updated dependencies [43b09a7]
- Updated dependencies [0000fa4]
- Updated dependencies [eb72cfa]
- Updated dependencies [03f9fd5]
- Updated dependencies [5cabc11]
- Updated dependencies [a4b6395]
- Updated dependencies [3bedfab]
- Updated dependencies [c94d12c]
- Updated dependencies [95aef70]
- Updated dependencies [bc47e48]
- Updated dependencies [85675fc]
- Updated dependencies [af6e7f6]
- Updated dependencies [02080f2]
- Updated dependencies [dab6eb8]
- Updated dependencies [ce6654d]
- Updated dependencies [28168ae]
- Updated dependencies [807cd9a]
- Updated dependencies [cfa865b]
  - @hyperdx/common-utils@0.32.0
## @hyperdx/app@2.42.0

### Minor Changes

- fe59feb: feat: Edit chart alerts in a side panel docked beside the chart editor, so the preview chart stays in view while the alert is tuned. "Add alert" / "Edit alert" opens the panel; a new alert is kept with "Add alert" and an existing one with "Update alert", while closing the panel discards the changes. The panel fills the editor's visible height and can be resized from its left edge. Save and Cancel move to the chart name row.
- 1773c11: feat: add a content font size preference

  Small text in the log/trace table was not adjustable without browser zoom, which
  also shrinks the surrounding chrome. User preferences now carry a **Content font
  size** setting (small / medium / large) that scales the search results table,
  data tables, expanded row JSON, and chart axes, legends, and tooltips. Small is
  the default and matches the previous sizes, so nothing changes until you opt in.

- 95aef70: Add an optional `eventNameExpression` to log sources, pointing at the
  OpenTelemetry log EventName. Source auto-detection sets it when an OTel log
  table has a string `EventName` column. The field is stored and editable only:
  nothing reads it yet. HDX-5787
- bc47e48: Add an optional `severityNumberExpression` to log sources, pointing at the
  numeric OpenTelemetry SeverityNumber (1-24). Source auto-detection sets it when
  an OTel log table has an integer `SeverityNumber` column. The field is stored
  and editable only: log levels still come from the severity text.
- 7eea0fd: feat: Pick any time zone in User Preferences, replacing the "Use UTC time" switch. Local and UTC stay pinned at the top of the list
- cfa865b: feat: expose group-by values to webhook body templates as `{{var.<column>}}`, and keep them on resolve notifications

### Patch Changes

- 9205823: feat: Broadcast adhoc dashboard filter conditions
- 3e9185d: feat: Filter search tile cells into adhoc dashboard filters
- 180a1f1: feat: Filter table tile group-by cells into adhoc dashboard filters
- 7db73cf: feat: Filter time chart series into adhoc dashboard filters
- a682ce1: feat: Render adhoc dashboard filter inputs
- 9891a79: feat: Add ad hoc dashboard filter schemas and types
- 43b09a7: feat: Add UI for configuring adhoc dashboard filters
- f56d760: feat: use the click-ui `Badge` for the sidebar "Beta", "UTC", and instance
  label badges, and for the "Latest", "New", and "Breaking" badges in What's new.
- 5253b86: fix: Stop abbreviating values between 500 and 999 as "0.5k"/"0.8k" on chart axes and in large number format
- 89ad276: When a removed column is one field in a multi-field sort, drop only that field
- 4d74bd5: fix: saved search is usable on a phone. Below 900px the filters open in a
  drawer, SELECT and ORDER BY stay behind "Columns", and the log table
  keeps the remaining screen.
- 72ed406: fix: the time picker no longer resets a selected date range to the current
  year. The range text now includes the year when the range isn't in the current
  year (or reaches into the future), so it survives being re-parsed.
- c0af58b: fix: Let the Services > Database query drawer use the span attribute skip index instead of scanning the whole trace window
- 0444e3e: fix: point the "Connect to Demo Server" connection at the new demo ClickHouse
- f33fc83: Count spans recorded with the legacy `STATUS_CODE_ERROR` status as errors on the Services and LLM dashboards. The errored-span definition now lives in one place and matches both that value and the short `Error` form, so those dashboards no longer undercount errors from collectors that emit the long form.
- a89b324: fix: the search page filter key limit (Filter Keys Fetch Limit, default 100)
  now applies to each Map column, so keys from `LogAttributes` no longer crowd out
  `ResourceAttributes` and other Map columns
- 7d77d27: fix: Fix table sort state and support order by without asc/desc
- f1bdd8f: Fix expanding a row on tables with a string `id` column: row details now keep the internal WHERE-clause id instead of being overwritten by the raw column value (ClickHouse#3370)
- f078e6b: feat: Add group-by to the search page histogram
- 0840b8b: feat: Support source remapping during ad hoc filter import
- 2c2295f: feat: use the click-ui `Badge` for all status badges on the Kubernetes dashboard
  and its node, namespace, and pod drawers.
- 0000fa4: feat: Add a line interpolation display setting (smooth, linear, step)
- 3db148a: feat: use the click-ui `Badge` for the model, provider, tool, agent, and chat
  role badges in the LLM span panel, session drawer, and dashboard.
- eb72cfa: feat(mcp): add `clickstack_service_health`, a one-call per-service health report for a trace source (throughput, error rate, p50/p95/p99, top error groups), compared with yesterday and ranked worst first
- 938494d: feat: use the click-ui `Badge` in the metric explorer, the chart series and
  formula editors, the tile alert editor, the materialized view indicator and
  modal, and the span kind in the row side panel.
- bc0a1e4: refactor: Look up every source in useMultipleGetKeyValues and keep partial results when one fails
- f21bdb8: fix: keys inside Map and JSON filter groups (e.g. `LogAttributes`) in the search
  page filter sidebar stay expanded or collapsed after scrolling them out of view
  and back.
- b000bbb: fix: add a key search to Map and JSON filter groups (e.g. `ResourceAttributes`,
  `LogAttributes`) in the search page filter sidebar, matching the value search on
  regular filters.
- 8b92b7a: fix: keys inside Map and JSON filter groups (e.g. `LogAttributes`) in the search
  page filter sidebar are sorted alphabetically, after keys with a selected value
  and pinned keys.
- 5cabc11: feat: Support distribution heatmaps calculated from promql data
- e7b38c2: fix: Show the Exception panel for logs and spans that carry `exception.type`,
  `exception.message` or `exception.stacktrace` attributes, not only for
  `exception` span events; show correlated logs in the trace waterfall even when
  their span was never exported, and color error logs and `exception` span events
  red there; and set `spanEventsValueExpression` and `spanLinksValueExpression` on
  the default trace sources so span exceptions and links load out of the box. The
  default sources also gain the summary and exponential histogram metric tables
  and drop key rollup tables the schema no longer creates.
- 097cb7c: fix: Read current OpenTelemetry HTTP and RPC semantic conventions (with deprecated names as fallbacks) in the span side panel and trace waterfall: redesign the HTTP panel around a request line and details, add a gRPC/RPC call panel, and flag correlated log errors and warnings by SeverityNumber or case-insensitive SeverityText
- 07de613: refactor: useMultipleAllFields returns each table connection's fields and columns.
- a4b6395: fix: render percentile aggregations (`p50`, `p90`, `p95`, `p99`) as `quantile`
  at the matching level. A chart config with `aggFn: 'p95'` rendered as the
  nonexistent ClickHouse function `p95If`. The AI assistant now returns
  percentiles with a level instead of a bare `quantile` (median), and v1 metric
  chart conversion keeps the percentile level.
- 3bedfab: feat: Support histogram heatmaps from PromQL bucket metrics
- c94d12c: fix: Resolve PromQL queries through the source instead of a caller-supplied connection and table

  The `/api/v1/prometheus/*` routes now take `sourceId` instead of `connectionId`, and read the connection and TimeSeries table from that PromQL source. Caller-supplied `database`/`table` params are ignored for ClickHouse and no longer forwarded to external Prometheus endpoints. Saved PromQL chart configs now require `source` and no longer persist `connection`, so provisioned dashboards with PromQL tiles must set `source`.

- 30debf9: speed up the sessions query by scoping the trace scan to RUM services
- b9f6170: chore: upgrade @tanstack/react-query to 5.90 and keep a cancelled filter
  values stream's partial results out of the cache
- 685f465: fix: Wrap long values in release marker tooltips instead of overflowing
- f41027f: feat: use the click-ui `Badge` for the exception breadcrumb status and level
  chips, the AI Assistant "Experimental" label, and the materialized view config
  pills. Remove the unused stacktrace, breadcrumb, and source map code from
  `LogSidePanelElements`, and the unused `StacktraceFrame` component.
- e3f24b9: fix: Preserve multi-line SQL in search replay links
- d341ba0: fix: resolve row details with the primary key plus whichever of `_block_number`
  / `_block_offset` the table persists, instead of falling back to the slow
  full-row lookup when only one of `enable_block_number_column` /
  `enable_block_offset_column` is set
- 3ff761b: feat: Remember the service map sampling rate in local storage; a `samplingFactor` URL param still takes precedence
- 96a0861: Show a Group by field count as a number on a narrow screen
- 85675fc: perf: stream filter values into the UI as each query finishes instead of
  waiting for all of them
- d3e6f96: feat: use the click-ui `Badge` for dashboard and alert tags, and for the
  "Experimental", "Beta", "Disabled", "Dev only", team member, and pending
  invite badges in settings.
- e8f0f94: fix: Keep the dashboard tile menu on screen when a dashboard has many groups, by moving the group list into a scrollable "Move to group" submenu
- af6e7f6: Add a description field to the tile editor. The description is now stored on the
  tile's chart config next to its name, and is kept for series tiles in the
  external Dashboards API.
- 02080f2: Add an optional tile description, accepted by the external Dashboards API and
  shown as an info tooltip next to the tile name.
- dab6eb8: fix: Read and show time picker ranges in the display time zone, so applying a range with "Use UTC time" on no longer shifts it for users west of UTC
- ce6654d: refactor: Store the time display setting as a time zone instead of a UTC switch, converting existing settings, and remove the `isUTC` option from `formatDate`
- d1cecf8: Fix the trace waterfall sticking on the previously opened span when another span of the same trace is opened, including from a session replay. Closing the result clears the selected span. Opening a different result selects that result's span, including when the waterfall stays mounted on cached data.
- 807cd9a: fix: the webhook form now compiles the body template as you edit it and shows
  the Handlebars error with its line and column. A broken template used to give
  no feedback until an alert failed to deliver.
- Updated dependencies [9205823]
- Updated dependencies [3e9185d]
- Updated dependencies [180a1f1]
- Updated dependencies [a682ce1]
- Updated dependencies [9891a79]
- Updated dependencies [43b09a7]
- Updated dependencies [27cccf7]
- Updated dependencies [8d064df]
- Updated dependencies [9c2c703]
- Updated dependencies [0000fa4]
- Updated dependencies [ee9f84b]
- Updated dependencies [24d324c]
- Updated dependencies [748315d]
- Updated dependencies [eb72cfa]
- Updated dependencies [cdfae45]
- Updated dependencies [b00d410]
- Updated dependencies [a06b17f]
- Updated dependencies [e494a46]
- Updated dependencies [03f9fd5]
- Updated dependencies [5cabc11]
- Updated dependencies [a4b6395]
- Updated dependencies [3bedfab]
- Updated dependencies [ca8e3fd]
- Updated dependencies [c94d12c]
- Updated dependencies [be5304d]
- Updated dependencies [95aef70]
- Updated dependencies [bc47e48]
- Updated dependencies [85675fc]
- Updated dependencies [af6e7f6]
- Updated dependencies [02080f2]
- Updated dependencies [dab6eb8]
- Updated dependencies [ce6654d]
- Updated dependencies [28168ae]
- Updated dependencies [ecddea2]
- Updated dependencies [807cd9a]
- Updated dependencies [cfa865b]
  - @hyperdx/common-utils@0.32.0
  - @hyperdx/api@2.42.0
## @hyperdx/common-utils@0.32.0

### Minor Changes

- 95aef70: Add an optional `eventNameExpression` to log sources, pointing at the
  OpenTelemetry log EventName. Source auto-detection sets it when an OTel log
  table has a string `EventName` column. The field is stored and editable only:
  nothing reads it yet. HDX-5787
- bc47e48: Add an optional `severityNumberExpression` to log sources, pointing at the
  numeric OpenTelemetry SeverityNumber (1-24). Source auto-detection sets it when
  an OTel log table has an integer `SeverityNumber` column. The field is stored
  and editable only: log levels still come from the severity text.
- cfa865b: feat: expose group-by values to webhook body templates as `{{var.<column>}}`, and keep them on resolve notifications

### Patch Changes

- 9205823: feat: Broadcast adhoc dashboard filter conditions
- 3e9185d: feat: Filter search tile cells into adhoc dashboard filters
- 180a1f1: feat: Filter table tile group-by cells into adhoc dashboard filters
- a682ce1: feat: Render adhoc dashboard filter inputs
- 9891a79: feat: Add ad hoc dashboard filter schemas and types
- 43b09a7: feat: Add UI for configuring adhoc dashboard filters
- 0000fa4: feat: Add a line interpolation display setting (smooth, linear, step)
- eb72cfa: feat(mcp): add `clickstack_service_health`, a one-call per-service health report for a trace source (throughput, error rate, p50/p95/p99, top error groups), compared with yesterday and ranked worst first
- 03f9fd5: fix(mcp): describe every MCP tool input, drop descriptions that restate schema constraints, and keep tool descriptions under Claude Code's 2 KB limit
- 5cabc11: feat: Support distribution heatmaps calculated from promql data
- a4b6395: fix: render percentile aggregations (`p50`, `p90`, `p95`, `p99`) as `quantile`
  at the matching level. A chart config with `aggFn: 'p95'` rendered as the
  nonexistent ClickHouse function `p95If`. The AI assistant now returns
  percentiles with a level instead of a bare `quantile` (median), and v1 metric
  chart conversion keeps the percentile level.
- 3bedfab: feat: Support histogram heatmaps from PromQL bucket metrics
- c94d12c: fix: Resolve PromQL queries through the source instead of a caller-supplied connection and table

  The `/api/v1/prometheus/*` routes now take `sourceId` instead of `connectionId`, and read the connection and TimeSeries table from that PromQL source. Caller-supplied `database`/`table` params are ignored for ClickHouse and no longer forwarded to external Prometheus endpoints. Saved PromQL chart configs now require `source` and no longer persist `connection`, so provisioned dashboards with PromQL tiles must set `source`.

- 85675fc: perf: stream filter values into the UI as each query finishes instead of
  waiting for all of them
- af6e7f6: Add a description field to the tile editor. The description is now stored on the
  tile's chart config next to its name, and is kept for series tiles in the
  external Dashboards API.
- 02080f2: Add an optional tile description, accepted by the external Dashboards API and
  shown as an info tooltip next to the tile name.
- dab6eb8: fix: Read and show time picker ranges in the display time zone, so applying a range with "Use UTC time" on no longer shifts it for users west of UTC
- ce6654d: refactor: Store the time display setting as a time zone instead of a UTC switch, converting existing settings, and remove the `isUTC` option from `formatDate`
- 28168ae: fix: Floor the time filter start to the precision of a coarse timestamp column (DateTime, DateTime64 below ms) when a finer column also filters the range, so a sub-second start no longer drops the rest of that tick; make the external API v2 search `maxResults` cap configurable via `EXTERNAL_API_SEARCH_MAX_RESULTS` (defaults to 2000, the previous hardcoded value)
- 807cd9a: fix: the webhook form now compiles the body template as you edit it and shows
  the Handlebars error with its line and column. A broken template used to give
  no feedback until an alert failed to deliver.
## @hyperdx/otel-collector@2.42.0

### Patch Changes

- ab563d2: Stop the collector logging a `ParseJSON` syntax error for every log body that
  contains braces but isn't JSON (e.g. Go struct or Python dict dumps). JSON
  extraction now only matches objects that start with a quoted key, and remaining
  parse failures in that statement group are silenced.
- ca8e3fd: fix: support https for the PromQL remote-write endpoint and ClickHouse scraping

  `CLICKHOUSE_PROMETHEUS_METRICS_ENDPOINT` now accepts an `http://` or `https://` URL as well as a bare `host:port`. With `https://`, the collector's `prometheusremotewrite` exporter sends metrics over TLS and verifies the server certificate against the system CAs. Previously the exporter always used plain HTTP, so an HTTPS-only endpoint could not receive metrics. This applies to both standalone and OpAMP-managed collectors.

  A bare `host:port` keeps plain HTTP, so existing setups are unchanged. The value must not include a path (`/write` is still appended automatically); the collector now exits at startup with a clear error if it does, or if the scheme is anything other than `http` or `https`.

  The OpAMP-managed collector scraped the endpoint over plain HTTP regardless of its scheme. ClickHouse is now scraped in its own `clickhouse` job (previously the `otelcol` job) using the endpoint's scheme, with TLS verification on. The collector's own metrics endpoint stays on plain HTTP. A bare `host:port` or `http://` endpoint behaves as before. Scraped ClickHouse metrics now carry `job="clickhouse"` instead of `job="otelcol"`.
Opt-in proxy-header SSO so Authentik is the only login. New self-contained
middleware/proxyHeaderAuth.ts + one hook in isUserAuthenticated + additive
PROXY_AUTH_* config (fails closed without a shared secret; never honors a bare
session in proxy mode) + gate sign-out on logout. Stock behavior when disabled.
Adversarially reviewed; typecheck + 15 tests + lint green. Edge controls
(strip inbound headers + NetworkPolicy + shared secret) are mandatory — see
GASCITY-FORK.md.
…city/hyperdx

On push to gascity-sso (+ dispatch): verify (lint/typecheck/unit tests) and build
the docker/hyperdx 'prod' target, assert the proxy-auth symbols are in the image
(no silent stock ship), and push :gascity-sso + :2.28.0-gascity-<sha>.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- EMAIL_RE drops the \x00-\x1f\x7f ranges (the prettier wrap had moved the regex
  off the eslint-disable-next-line); control chars are now checked in code via
  hasControlChar(), so no lint carve-out is needed.
- CI test step uses an anchored --testPathPattern so it runs only the two DB-free
  unit suites (a bare 'config' also matched renderChartConfig.test.ts → needs DBs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Accept a distinct previous value during attended rotations while keeping the current value mandatory and the middleware fail-closed. Tighten the production artifact guard and prevent image publication before verification succeeds.
Run auth verification for ordinary pull requests without secrets or write credentials. Replace inherited pull_request_target AI reviewers with visibly disabled manual stubs until a separately reviewed isolation design exists.
…e logged or forwarded

The gate injects the shared secret on every request. Stock HyperDX copies
req.headers verbatim in two places: the pino-http request log (stdout, shipped
to the log store) and the ClickHouse proxy (forwarded to the connection host,
which any HyperDX user can configure). consumeProxyAuthSecret is mounted first
in api-app.ts, parks the presented value in a WeakMap keyed by the request, and
deletes it from headers/rawHeaders; sharedSecretOk reads the parked value.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- derive the image/CODE_VERSION tag from packages/app/package.json instead of
  the hard-coded 2.28.0
- exact-file symbol guard: the 2.42 API build is flat (build/*.js, rootDir=src),
  not build/src/*.js; also guard consumeProxyAuthSecret and the auth.ts hook
- run the fork-owned proxyAuth* suites

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… hosts

The ClickHouse proxy copies every incoming request header (httpxy
setupOutgoing: outgoing.headers = { ...req.headers }) to the connection's
host, which any authenticated user can set to an arbitrary URL. That hands the
browser's session cookie, the SSO gate's cookie (_oauth2_proxy) and any bearer
token to that host. ClickHouse authenticates with the X-ClickHouse-* headers the
proxy sets, so drop cookie and authorization from the outgoing request.
Upstreamable as-is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Once gascity-sso is the default branch, schedule / issues / issue_comment /
pull_request_target events read their workflow files from it, so upstream's
Release Nightly, Vulnerability Alerts, Claude/Deep review, vouch, etc. would run
on the public fork (and fail, or act with the fork's GITHUB_TOKEN). Keep only
gascity-image.yml and gascity-pr.yml, and make both fail if a rebase re-adds an
inherited workflow file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ke harness

- gascity-sso is the default branch; main is frozen
- rebase recipe: keep inherited workflows deleted, verify before pushing (a
  textually clean rebase broke three ways on 2.28 -> 2.42)
- document the hook shape, consumeProxyAuthSecret and the ClickHouse proxy
  credential strip; secret-hygiene and self-telemetry requirements
- gascity/smoke: local auth-matrix + leak checks against FerretDB

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

CodeQL (js/polynomial-redos) flagged EMAIL_RE: its domain part,
[^\s@,]+\.[^\s@,]+, is ambiguous because the label class also matches '.',
so a long dotted header value backtracks quadratically (about 5 s for 50k
characters). It was only reachable after the shared secret matched, but it is
fork-owned code on the auth path.

Validate structurally instead: at most 254 characters (RFC 5321), no
whitespace or comma, exactly one '@' with a non-empty local part, and two or
more non-empty dot-separated domain labels. This is stricter only for empty
labels ("a@b..c", "a@.b.c", "a@b.c."), which no gate-asserted address has.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@julianknutsen

Copy link
Copy Markdown
Author

Force-pushed this PR's own branch, f19ea53a → c160553b, to add stack commit 12 a7c6d7cf before the integration merge: fix(auth): validate the gate email header in linear time. It fixes the only CodeQL alert on fork code (js/polynomial-redos in proxyHeaderAuth.ts).

Re-verified on c160553b:

  • lint: 301 warnings, 0 errors
  • tsc: clean
  • fork Jest suites: 72/72
  • Docker build: passes
  • in-image symbol guard: 8/8
  • smoke: 21/21 plus T16; /ready returns 200

The body is updated to match.

🤖 Generated with Claude Code

julianknutsen and others added 4 commits October 24, 2026 09:36
… capture

consumeProxyAuthSecret takes the shared secret off the request before any
Express middleware can log or forward it, but the bundled OpenTelemetry
SDK's HTTP instrumentation runs earlier, at the Node http server. With
HDX_NODE_ADVANCED_NETWORK_CAPTURE on it records every incoming request header
on the server span as the request arrives. The smoke harness, run with it
on, found http.request.header.x_hdx_proxy_auth_secret (plus the session
cookie, the gate cookie and any bearer token) in the exported traces.

The setting is opt-in and is not used by our deployment, but it is the
documented way to debug request headers, so turn that posture into a startup
error instead of a silent leak, like the missing-secret check. Truthy
spellings mirror the SDK's own parsing (true/yes/1, case-insensitive,
trimmed). The error names the setting, never the secret.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…and the proxy strip

- gascity-image: packages: write moves from the workflow to the build job,
  and both checkouts stop persisting the token. The verify job runs
  `yarn install`, which runs dependency lifecycle scripts; with the persisted
  credential and a workflow-wide packages: write, a compromised dependency
  could have read a token able to push to the image repository.
- Both symbol guards now also require the advanced-network-capture refusal in
  config.js and the credential strip (removeHeader) in
  routers/api/clickhouseProxy.js, so a rebase that drops either fails CI
  instead of shipping.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…etry and rotation

New smoke cases (fake values only):
- T5b canonical-case gate headers; T5c trailing-dot domain (401); T5d a
  plus-address is its own identity, never linked to the base address
- T9b an identity switch issues a new session id (no fixation)
- T13c a bearer token is not forwarded to the connection host
- T14c/T14d the gate cookie and a bearer token never reach stdout; T14e/T14f
  exercise the error-handler and console.error paths with the secret present
- T15b no browser credential in the OTLP export
- T16b/T16c advanced network capture refuses to start, without echoing the
  secret
- `telemetry`: the production posture (no HYPERDX_API_KEY, no OTLP endpoint)
  starts no SDK, hands out no apiKey and exports nothing
- `rotation`: the current/previous overlap window, which GASCITY-FORK.md
  already claimed the harness covered
- SMOKE_LOG_LEVEL (e.g. trace) for a verbose-logging run

GASCITY-FORK.md: document the capture refusal, that the gate cookie must be
SameSite=Lax or Strict (it is the effective credential in proxy mode), that
the email is the identity, and that connection hosts are user-editable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…into gascity-sso

The first parent is the reviewed patch stack on @hyperdx/app@2.42.0; the second
is the previous gascity-sso (2.28.0 base, 7b24ad4; to be tagged
gascity-sso/2.28.0), kept reachable so the deployed 2.28.0-gascity-7b24ad4fa2f9
image stays traceable to its source. Tree == the first parent (merge -s ours).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@julianknutsen

Copy link
Copy Markdown
Author

Security and correctness review (head 2ae4cede)

No blocking issues. I found one latent leak path, plus two CI hardening gaps, and fixed all three on this branch. Merge with "Create a merge commit": squash and rebase merges are enabled on this repository, and either one would break the integration shape.

What changed

I force-pushed c160553b → 2ae4cede and added three commits to the stack before the integration merge.

  • Commits 1–12 are unchanged: git range-diff shows = for all of them.
  • The merge tree still equals the stack tree.
  • The current gascity-sso tip, 7b24ad4f, is still the second parent, so the branch is strictly up to date.
  • The generation recipe in GASCITY-FORK.md yields exactly the 15 fork commits.
# Commit Why
13 3f2370eb fix(auth): refuse to start proxy auth with the SDK's advanced network capture See F1b below.
14 c3b763d7 ci(gascity): least-privilege image workflow; guard the capture check and the proxy strip Two changes: packages: write is now granted only to the publishing job, and neither checkout persists the token.
15 8855373e test(gascity): smoke attack cases for credentials, error paths, telemetry and rotation New harness cases, plus doc updates.

Findings

Sev Where Finding Status
Medium (latent) packages/api/src/config.ts (fork block) The bundled OpenTelemetry SDK's HTTP instrumentation runs at the Node http server, before consumeProxyAuthSecret. With HDX_NODE_ADVANCED_NETWORK_CAPTURE on, it records every request header on the server span. The smoke run with it on exported http.request.header.x_hdx_proxy_auth_secret, the session cookie, the gate cookie and a bearer token. It is opt-in, and our deployment does not set it. Fixed in 13: the API refuses to start with this setting and proxy auth both on. Covered by 10 unit tests and smoke T16b/T16c. The new T16b fails against c160553b.
Low .github/workflows/gascity-image.yml packages: write applied to the whole workflow, and both checkouts persisted the token. Meanwhile verify runs yarn install, which runs dependency lifecycle scripts. Fixed in 14
Low both workflows The symbol guard did not cover the ClickHouse-proxy credential strip, so a rebase that dropped it would still pass CI. Fixed in 14: routers/api/clickhouseProxy.js:removeHeader is now guarded.
Info GASCITY-FORK.md The doc said the smoke harness covered the rotation window. It did not. Fixed in 15 with a rotation subcommand. The doc now also covers three more points: the gate cookie must be SameSite=Lax or Strict (in proxy mode it is the credential that counts), the email is the identity, and connection hosts are user-editable.
Medium (pre-existing, upstream design) connection hosts Any user can point a connection at any host the API can reach. The ClickHouse proxy then sends that user's requests there (any path, the user's body and added headers) and returns the response. This PR stops the proxy from carrying browser credentials and the shared secret, but the request itself is upstream behavior and unchanged since 2.28. Not fixed here; follow-up. Restrict egress in the deployment, and report it upstream. This does not block the merge.
Low proxyHeaderAuth.ts hasProxyAuthSession The email-domain allowlist is checked when a session is established, but not on later requests whose session already matches. This only matters if the gate itself asserts a foreign domain. Not changed

Verified

1. Header trust is gated on the shared secret.

  • The identity header is honored only after timingSafeEqual matches the current or previous secret.
  • Both comparisons are always computed. Length is checked first, an empty configured slot never matches, and startup refuses both a missing secret and previous == current.
  • getProxyAuthEmail is the only reader of the identity header.
  • Paths that skip isUserAuthenticated never read it:
    • root router: /health, /ready, /installation, /login/password, /register/password, /team/setup/:token, /logout
    • Bearer-only: /mcp and /api/v2
    • OpAMP, on its own port
    • Next.js: /api/config and static assets
  • There are no websocket or SSE routes outside the guarded routers.

2. Sessions bind to the verified identity.

  • req.login() regenerates the session (passport 0.6). Smoke T9b confirms a new session id after an identity switch.
  • Every request re-checks the session user's email against the vouched identity, and bare sessions get 401.

3. Email edge cases.

  • Values are trimmed and lowercased.
  • The check requires exactly one @, no whitespace, comma or control characters, non-empty labels (so a trailing dot is rejected) and at most 254 characters. The domain allowlist is an exact match.
  • Plus-addresses and lookalike local parts become distinct accounts, never links (smoke T5c/T5d).
  • The user lookup is an exact match on the lowercased value, with no regex.

4. The secret is stripped before every logger and the proxy.

  • consumeProxyAuthSecret is the first app middleware; only Express's own init runs before it.
  • I ran the smoke harness with SMOKE_LOG_LEVEL=trace and OTEL_LOG_LEVEL=debug. That produced several thousand stdout lines, including the OTLP exporter's debug dump of every record. None contained the secret, the session cookie, the gate cookie or a bearer token, on the 400 error-handler path and the console.error path as well.
  • The only exception was the opt-in capture above.

5. The ClickHouse proxy strips credentials.

  • cookie and authorization are removed, and the secret is already consumed. The capture host received none of them (T13–T13c).
  • X-Auth-Request-Email and X-Forwarded-* still reach the connection host.

6. Nothing in the 2.28 patch was lost.

  • The hook's decision table is the same as in 2.28. The success path now runs through upstream's setBusinessContext and query attribution.
  • The email check is stricter only for empty labels and values over 254 characters.

7. Migrations are additive.

  • 2.42 adds no new migrate-mongo scripts.
  • The startup backfill catches its own errors.
  • The new indexes are additive, and accessKey has a UUID default, so its unique index can build.
  • seed/verify-seed read back cleanly on 2.42.

8. Gates.

  • Lint: 0 errors, 301 warnings (budget 302, none on fork lines).
  • tsc: clean.
  • Jest: 4 suites, 82/82.
  • API build plus the PR symbol guard: 10/10.
  • Docker --target prod builds a fresh image; the exact in-image guard passes 10/10.

9. Smoke against the fresh image.

  • Setup: FerretDB 2.5.0, fake secrets, 1000:1000, --cap-drop ALL.
  • auth passes 31/31 three times: at the default log level, at trace level, and in the production posture (no HYPERDX_API_KEY, no OTLP endpoint).
  • telemetry passes 4/4: the SDK is skipped in the API and the alert task, /api/config returns no apiKey, and nothing is exported.
  • rotation passes 7/7 and failclosed 3/3.

10. CodeQL. 2ae4cede has the same 70 alerts as c160553b, matched by path and rule, and all of them are on upstream lines; the polynomial-regex alert now points at upstream common-utils. The new commits add none. CodeQL is not a required check. auth quality gate is green on 2ae4cede.

Verdict: OK to merge now, with Create a merge commit. The image digest for the repin must come from the gascity-image run for this merge.

🤖 Generated with Claude Code

@julianknutsen

Copy link
Copy Markdown
Author

Addendum: I re-ran the rollback rehearsal against this head's code, with the previous fork image and this PR's image taking turns on one fresh FerretDB 2.5.0 database: 2.28 → 2.42 → 2.28.

  • Phase 1 (2.28). The seeded dashboard, saved search, webhook and alert all read back.
    • It also serves as the negative control for the extended matrix. 2.28 passes 21/31 and fails exactly T13–T13c, T14–T14d and T15/T15b.
    • It also fails T5c, but only on the status code: it returns 403 instead of 401, and the request is still denied.
  • Phase 2 (2.42). All seeded objects read back.
    • The startup backfill ran once (scannedCount 1, updatedCount 2), and there were no index or validation errors.
    • auth passes 31/31.
  • Phase 3 (back to 2.28 on the 2.42-touched data). It starts cleanly, with no error-level, index or validation lines and 0 restarts. Everything reads back, and the auth gate behaves as in phase 1.

One operational caveat for anyone rolling back: the 2.28 image answers 404 on /ready. A rollback must restore a /health readiness probe together with the image. The deployment repo's rollback note now says so.

🤖 Generated with Claude Code

@julianknutsen
julianknutsen merged commit 753b16d into gascity-sso Oct 25, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.