Skip to content

feat(services): derive the Apdex threshold from a detected app kind - #439

Open
Makisuo wants to merge 3 commits into
mainfrom
feat/service-app-kind-apdex
Open

feat(services): derive the Apdex threshold from a detected app kind#439
Makisuo wants to merge 3 commits into
mainfrom
feat/service-app-kind-apdex

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Why

Apdex on the service detail pane is scored against a fixed T = 500 ms for every service. That's a reasonable target for a backend API and a meaningless one for a browser app, where an entry span is a request made from a device on someone's home wifi — a "web page" service reads as permanently frustrated and the chart stops carrying signal.

What changed

Detection. service_platforms_hourly already answered where a service runs (k8s / cloudflare / lambda). It could not answer what kind of app it is, because the only signal it carried for that was maple.sdk.type — present solely on services instrumented with a Maple SDK. Migration 0015 adds the vendor-neutral markers so a customer on vanilla OTel browser JS is covered too:

column resource attribute
TelemetrySdkLanguage telemetry.sdk.language (webjs = OTel browser SDK)
BrowserPlatform browser.platform (per semconv, only ever set in a browser)
DeviceType device.type

classifyServiceAppKind resolves browser | mobile | backend | unknown. Browser is checked first on purpose: a browser app can carry cloud.provider from a CDN or a k8s.* leak from an OTel gateway it was proxied through, and neither makes it a backend — while the reverse mistake is impossible, since a server never reports browser.platform.

Two pre-existing bugs this surfaced:

  • Maple's own browser SDK writes maple.sdk.type = "browser" (packages/browser/src/tracing.ts:86) but toServicePlatformRow only matched "client"every browser service was classifying as unknown.
  • The service detail page never fetched platform data at all; the standalone servicePlatforms handler had no caller.

Threshold. browser 2500 ms (the Core Web Vitals "good" LCP boundary, so the frustrated line at 4T lands at 10 s), mobile 1000 ms, everything else unchanged at 500 ms. One table in @maple/domain/service-app-kind.

UI. An app-kind badge next to the service title (renders nothing for unknown — a badge saying the product can't tell is worse than no badge), and the Apdex card header states the active target (Target < 2.5s) via the metrics grid's existing headerValue slot. A score is uninterpretable without the T that produced it.

Reviewer notes

The load-bearing decision. canUseAnnualServiceOverview requires apdexThresholdMs === 500, and the Overview chart is a single allMetrics request. Threading a browser target through it would knock throughput, latency, and error rate off the one-year rollup and onto the 30-day raw path for the sake of one series. So payload.timeseries is forwarded untouched and Apdex is re-scored by a second, narrower query — and only when T differs from the default. ch.test.ts asserts that fork directly.

Known limitation, surfaced rather than papered over. The override reads service_overview_spans (30-day TTL) while the rest of the chart reaches a year back, so on a longer range a browser service's early Apdex buckets have no score. apdexScore widens to number | null and those buckets render as gaps. Carrying the 500 ms number through would silently mix two thresholds in one series; zero would draw a crater reading as "every user was frustrated".

Migration safety. 0015 is requiredForIngest: false: service_platforms_hourly is filled by a materialized view, never by a native INSERT, so a BYO-ClickHouse cluster that hasn't applied it keeps ingesting correctly and simply classifies its services exactly as it does today. Gating ingest here would route every BYO org back to managed over a display-only classification. There is deliberately no backfillmax() over the viewed window means one hour of fresh telemetry classifies a service, and the table's single sum column (SpanCount) would double-count if re-inserted.

The local-store schema gate required the full v4 → v5 ceremony: frozen local-schema-v5.sql snapshot, appended identity, and a v4-to-v5-service-app-kind migration module. Unlike v3 → v4 the bootstrap pass alone isn't enough — the table already exists, so its CREATE TABLE IF NOT EXISTS is a no-op and the ALTERs have to run first.

Out of scope: top-operations.ts (MCP/CLI path) and the alert-rule Apdex default keep their hardcoded 500 ms. Worth a follow-up now that appKind exists, but changing an alert's T silently re-scores live rules.

Verification

  • bun typecheck clean across all 37 workspace tasks.
  • bun run clickhouse:schema:check and bun run tinybird:manifest:check green, including the local-schema manifest gate at v5.
  • Tests: query-engine 1016, domain 497, cli 430, api 1697, web warehouse 60 — all passing. New coverage: a classifier case table (Maple browser SDK, vanilla OTel webjs, mobile, k8s, cloudflare, all-empty → unknown, plus the browser-beats-infrastructure precedence), the migration's shape, the rollup-guard fork, and mergeApdexOverride's null semantics.

⚠️ Not verified in a browser. The badge and the target hint have not been seen rendering against real data — the classification and threshold logic are unit-tested, but the visual result isn't. Worth a look before merge.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Makisuo and others added 3 commits August 12, 2026 00:17
Apdex on the service detail pane was scored against a fixed T = 500 ms for
every service. That is a target for a backend API and meaningless for a
browser app, where an entry span is a request from a device on someone's
home wifi — such a service reads as permanently frustrated and the chart
stops carrying signal.

Detection. `service_platforms_hourly` already answered "where does this
service run"; it could not answer "what kind of app is this", because the
only signal it carried for that was `maple.sdk.type`, present solely on
services using a Maple SDK. Migration 0015 adds the vendor-neutral markers
(`telemetry.sdk.language`, `browser.platform`, `device.type`) so a customer
on vanilla OTel browser JS is classified too. `classifyServiceAppKind`
resolves browser | mobile | backend | unknown, checking browser first: a
browser app can carry `cloud.provider` from a CDN or a `k8s.*` leak from a
gateway it was proxied through, while a server never reports
`browser.platform`.

Two things this surfaced that were already broken:
  - Maple's own browser SDK writes `maple.sdk.type = "browser"` but the
    platform classifier only matched `"client"`, so every browser service
    was classifying as `unknown`.
  - The service detail page never fetched platform data at all; the
    standalone `servicePlatforms` handler had no caller.

Threshold. browser 2500 ms (the Core Web Vitals "good" LCP boundary, which
puts the frustrated line at 4T = 10 s), mobile 1000 ms, everything else
unchanged at 500 ms.

`canUseAnnualServiceOverview` requires `apdexThresholdMs === 500`, and the
Overview chart is a single `allMetrics` request — threading a browser
target through it would drop throughput, latency AND error rate onto the
30-day raw path for the sake of one series. So `payload.timeseries` is
forwarded untouched and Apdex is re-scored by a second, narrower query,
only when T differs from the default.

That override reads `service_overview_spans` (30-day TTL) while the rest of
the chart reaches a year back, so on a longer range its early buckets have
no score. `apdexScore` widens to `number | null` and those buckets render
as gaps: carrying the 500 ms number through would silently mix two
thresholds in one series, and zero would draw a crater reading as "every
user was frustrated".

Migration 0015 is `requiredForIngest: false` — `service_platforms_hourly`
is filled by a materialized view, never by a native INSERT, so a BYO
cluster that has not applied it keeps ingesting correctly and simply
classifies its services exactly as it does today. There is no backfill:
`max()` over the viewed window means one hour of fresh telemetry classifies
a service, and the table's one `sum` column would double-count if
re-inserted.
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.

1 participant