From bb316998ced92c5c384c07088a2722ac02333323 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Sat, 22 Aug 2026 14:45:20 +0200 Subject: [PATCH] fix(web): keep synthetic fingerprints out of the errors sparkline query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `error_issues.fingerprint_hash` is shared by three issue kinds: "error" rows hold the decimal UInt64 from ClickHouse, while "alert" and "integration" rows reuse the column for a synthetic key (`alert:{ruleId}:{groupKey}`, `planetscale:{database}:{event}`). The errors hub sent every visible issue's fingerprint to `errorsSpark`, which lowers the list into `FingerprintHash IN (toUInt64('…'), …)`. One alert-backed issue on screen made ClickHouse reject the whole batch, so the sparklines 500'd for every row in the list. Filter to error-kind issues before the round-trip — the same guard `ErrorIssueReadModelsService.getIssue` already applies on the detail path, which is why issue detail never hit this. Rows without a series already degrade to an empty spark, and an all-alert list skips the request entirely. --- apps/web/src/components/errors/errors-hub.tsx | 9 +++++-- apps/web/src/lib/models/error-signal.test.ts | 17 ++++++++++++- apps/web/src/lib/models/error-signal.ts | 24 ++++++++++++++++++- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/errors/errors-hub.tsx b/apps/web/src/components/errors/errors-hub.tsx index e146c07b01..a6a123c1c6 100644 --- a/apps/web/src/components/errors/errors-hub.tsx +++ b/apps/web/src/components/errors/errors-hub.tsx @@ -20,7 +20,12 @@ import { } from "@/lib/services/atoms/warehouse-query-atoms" import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" import { errorIssueFromV2 } from "@/lib/services/error-issues" -import { buildErrorSignals, indexInvestigationsByIssue, type ErrorSignal } from "@/lib/models/error-signal" +import { + buildErrorSignals, + indexInvestigationsByIssue, + sparkFingerprintHashes, + type ErrorSignal, +} from "@/lib/models/error-signal" import { clearedSelection, type IssueSelectionMsg, @@ -277,7 +282,7 @@ export function ErrorsHub(props: ErrorsHubProps) { // The sparkline set follows the rows actually on screen, so a filtered view // never pays for fingerprints it will not draw. - const fingerprintHashes = useMemo(() => issues.map((issue) => issue.fingerprintHash), [issues]) + const fingerprintHashes = useMemo(() => sparkFingerprintHashes(issues), [issues]) const bucketSeconds = useMemo(() => { const startMs = Date.parse(props.range.startTime.replace(" ", "T") + "Z") diff --git a/apps/web/src/lib/models/error-signal.test.ts b/apps/web/src/lib/models/error-signal.test.ts index 0a5c1aefce..afdbfc7d51 100644 --- a/apps/web/src/lib/models/error-signal.test.ts +++ b/apps/web/src/lib/models/error-signal.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest" import type { WorkflowState } from "@maple/domain/http" import type { V2Investigation } from "@maple/domain/http/v2" -import { densifySpark, resolveSignalState, surgeRatio } from "./error-signal" +import { densifySpark, resolveSignalState, sparkFingerprintHashes, surgeRatio } from "./error-signal" const HOUR_MS = 3_600_000 @@ -48,6 +48,21 @@ describe("resolveSignalState", () => { }) }) +describe("sparkFingerprintHashes", () => { + it("keeps only the issues whose fingerprint the warehouse can parse", () => { + // Alert and integration issues reuse the fingerprint column for a + // synthetic key. Sending one to the batched spark query makes ClickHouse + // reject `toUInt64('alert:…')` and fails the sparklines for every row. + const hashes = sparkFingerprintHashes([ + { kind: "error", fingerprintHash: "753390793895937054" }, + { kind: "alert", fingerprintHash: "alert:550bb2c5-8fe0-4ab9-b2f8-e566674cb0a2:scraper" }, + { kind: "integration", fingerprintHash: "planetscale:maple:branch.out_of_memory" }, + { kind: "error", fingerprintHash: "1037926342141719040" }, + ]) + expect(hashes).toEqual(["753390793895937054", "1037926342141719040"]) + }) +}) + describe("densifySpark", () => { const window = { startMs: 0, endMs: 4 * HOUR_MS, bucketMs: HOUR_MS } diff --git a/apps/web/src/lib/models/error-signal.ts b/apps/web/src/lib/models/error-signal.ts index a76af8d52f..4576180ddc 100644 --- a/apps/web/src/lib/models/error-signal.ts +++ b/apps/web/src/lib/models/error-signal.ts @@ -10,7 +10,13 @@ // // An `ErrorSignal` is those three joined on the fingerprint hash. -import type { ErrorIssueDocument, ErrorIssueId, IssueSeverity, WorkflowState } from "@maple/domain/http" +import type { + ErrorIssueDocument, + ErrorIssueId, + IssueKind, + IssueSeverity, + WorkflowState, +} from "@maple/domain/http" import type { V2Investigation } from "@maple/domain/http/v2" import type { ErrorByType } from "@/api/warehouse/errors" @@ -126,6 +132,22 @@ export function resolveSignalState( return { kind: "workflow", state: issue.workflowState } } +/** + * The fingerprints a sparkline can actually be drawn for. + * + * Only `kind === "error"` issues carry a real ClickHouse fingerprint (a decimal + * UInt64 string). Alert and integration issues reuse the column for a synthetic + * key — `alert:{ruleId}:{groupKey}`, `planetscale:{database}:{event}` — which + * `toUInt64()` rejects, failing the whole batched spark query for every row on + * screen. `ErrorIssueReadModelsService.getIssue` skips the warehouse for the + * same reason. + */ +export function sparkFingerprintHashes( + issues: ReadonlyArray<{ readonly kind: IssueKind; readonly fingerprintHash: string }>, +): ReadonlyArray { + return issues.filter((issue) => issue.kind === "error").map((issue) => issue.fingerprintHash) +} + /** * Join issues (the spine) with warehouse volume and trend. *