-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix(webapp,clickhouse): stop invalid customer queries alerting, and isolate Sentry scope per request #4372
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix(webapp,clickhouse): stop invalid customer queries alerting, and isolate Sentry scope per request #4372
Changes from all commits
df7eb05
e6f4bde
539199a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: fix | ||
| --- | ||
|
|
||
| Fixed error reports being attributed to the wrong request when several requests were in flight at once. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: fix | ||
| --- | ||
|
|
||
| Invalid queries sent to the query API are no longer treated as internal errors, and a query that does fail is now recorded together with the query text that produced it. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import { context } from "@opentelemetry/api"; | ||
| import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; | ||
| import * as Sentry from "@sentry/remix"; | ||
| import sentryRemix from "@sentry/remix"; | ||
| import { afterEach, beforeAll, describe, expect, it } from "vitest"; | ||
|
|
||
| /** | ||
| * Two overlapping requests, each tagging its own isolation scope, mirroring what | ||
| * `SentryHttpInstrumentation` does per incoming request. Returns what each one | ||
| * reads back after the other has started. | ||
| */ | ||
| async function raceTwoRequests(): Promise<Record<string, unknown>> { | ||
| const observed: Record<string, unknown> = {}; | ||
|
|
||
| const handleRequest = (name: string, holdMs: number) => | ||
| Sentry.withIsolationScope(async () => { | ||
| Sentry.getIsolationScope().setTag("request", name); | ||
| await new Promise((resolve) => setTimeout(resolve, holdMs)); | ||
| observed[name] = Sentry.getIsolationScope().getScopeData().tags.request; | ||
| }); | ||
|
|
||
| await Promise.all([handleRequest("slow", 30), handleRequest("fast", 5)]); | ||
|
|
||
| return observed; | ||
| } | ||
|
|
||
| describe("Sentry request isolation", () => { | ||
| beforeAll(() => { | ||
| Sentry.init({ dsn: undefined, defaultIntegrations: false, skipOpenTelemetrySetup: true }); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| context.disable(); | ||
| }); | ||
|
|
||
| it("leaks the isolation scope between concurrent requests without SentryContextManager", async () => { | ||
| new NodeTracerProvider().register(); | ||
|
|
||
| const observed = await raceTwoRequests(); | ||
|
|
||
| expect(observed).toEqual({ slow: "fast", fast: "fast" }); | ||
| }); | ||
|
|
||
| it("keeps each request's isolation scope separate with SentryContextManager", async () => { | ||
| new NodeTracerProvider().register({ contextManager: new sentryRemix.SentryContextManager() }); | ||
|
|
||
| const observed = await raceTwoRequests(); | ||
|
|
||
| expect(observed).toEqual({ slow: "slow", fast: "fast" }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -171,13 +171,19 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter { | |
| ); | ||
|
|
||
| if (clickhouseError) { | ||
| this.logger.error("Error querying clickhouse", { | ||
| const errorLogFields = { | ||
| name: req.name, | ||
| error: clickhouseError, | ||
| query: req.query, | ||
| params, | ||
| queryId, | ||
| }); | ||
| }; | ||
|
|
||
| if (isClickhouseQuotaError(clickhouseError)) { | ||
| this.logger.warn("Query exceeded a ClickHouse limit", errorLogFields); | ||
| } else { | ||
| this.logger.error("Error querying clickhouse", errorLogFields); | ||
| } | ||
|
|
||
| recordClickhouseError(span, clickhouseError); | ||
|
|
||
|
|
@@ -260,6 +266,11 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter { | |
| * These will be merged with the default settings. | ||
| */ | ||
| settings?: ClickHouseSettings; | ||
| /** | ||
| * Extra fields to attach to the error log if the query fails. Use this to | ||
| * record what produced the SQL, e.g. the TSQL a caller actually wrote. | ||
| */ | ||
| logFields?: Record<string, unknown>; | ||
| }): ClickhouseQueryWithStatsFunction<z.input<TIn>, z.output<TOut>> { | ||
| return async (params, options) => { | ||
| const queryId = randomUUID(); | ||
|
|
@@ -320,13 +331,20 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter { | |
| ); | ||
|
|
||
| if (clickhouseError) { | ||
| this.logger.error("Error querying clickhouse", { | ||
| const errorLogFields = { | ||
| ...req.logFields, | ||
| name: req.name, | ||
| error: clickhouseError, | ||
| query: req.query, | ||
| params, | ||
| queryId, | ||
| }); | ||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| if (isClickhouseQuotaError(clickhouseError)) { | ||
| this.logger.warn("Query exceeded a ClickHouse limit", errorLogFields); | ||
| } else { | ||
| this.logger.error("Error querying clickhouse", errorLogFields); | ||
| } | ||
|
|
||
| recordClickhouseError(span, clickhouseError); | ||
|
|
||
|
|
@@ -453,13 +471,19 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter { | |
| ); | ||
|
|
||
| if (clickhouseError) { | ||
| this.logger.error("Error querying clickhouse", { | ||
| const errorLogFields = { | ||
| name: req.name, | ||
| error: clickhouseError, | ||
| query: req.query, | ||
| params, | ||
| queryId, | ||
| }); | ||
| }; | ||
|
|
||
| if (isClickhouseQuotaError(clickhouseError)) { | ||
| this.logger.warn("Query exceeded a ClickHouse limit", errorLogFields); | ||
| } else { | ||
| this.logger.error("Error querying clickhouse", errorLogFields); | ||
| } | ||
|
|
||
| recordClickhouseError(span, clickhouseError); | ||
|
|
||
|
|
@@ -599,13 +623,19 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter { | |
|
|
||
| span.setAttributes({ "clickhouse.rows": rowCount }); | ||
| } catch (error) { | ||
| self.logger.error("Error streaming clickhouse", { | ||
| const errorLogFields = { | ||
| name: req.name, | ||
| error, | ||
| query: req.query, | ||
| params, | ||
| queryId, | ||
| }); | ||
| }; | ||
|
|
||
| if (error instanceof Error && isClickhouseQuotaError(error)) { | ||
| self.logger.warn("Streamed query exceeded a ClickHouse limit", errorLogFields); | ||
| } else { | ||
| self.logger.error("Error streaming clickhouse", errorLogFields); | ||
| } | ||
|
|
||
| if (error instanceof Error) { | ||
| recordClickhouseError(span, error); | ||
|
|
@@ -1001,6 +1031,29 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * ClickHouse error types raised by a query that is valid but asks for more than | ||
| * the caller is allowed to spend. The caller gets a 4xx and there is nothing on | ||
| * our side to fix, so these are logged at warn rather than error. | ||
| */ | ||
| const CLICKHOUSE_QUOTA_ERROR_TYPES = new Set([ | ||
| "MEMORY_LIMIT_EXCEEDED", | ||
| "TIMEOUT_EXCEEDED", | ||
| "TOO_SLOW", | ||
| "TOO_MANY_ROWS", | ||
| "TOO_MANY_BYTES", | ||
| "TOO_MANY_ROWS_OR_BYTES", | ||
| "QUERY_WAS_CANCELLED", | ||
| ]); | ||
|
Comment on lines
+1039
to
+1047
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 QUERY_WAS_CANCELLED classified as a warn-level quota error
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| function isClickhouseQuotaError(error: Error): boolean { | ||
| return ( | ||
| error instanceof ClickHouseError && | ||
| error.type !== undefined && | ||
| CLICKHOUSE_QUOTA_ERROR_TYPES.has(error.type) | ||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| function recordClickhouseError(span: Span, error: Error): void { | ||
| if (error instanceof ClickHouseError) { | ||
| span.setAttributes({ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔍 SentryContextManager reached via default (CommonJS) export is unverified from source
createContextManagerinstantiatesnew sentryRemix.SentryContextManager()from the default import of@sentry/remix(apps/webapp/app/v3/tracer.server.ts:228-230). Dependencies are not installed in this checkout, so I could not confirm from source thatSentryContextManageris present on the default export of@sentry/remix@9.46.0(it originates in@sentry/opentelemetry). If it is not re-exported on the default object,createContextManager()throws at server boot on both the enabled path (tracer.server.ts:327) and the disabled path (tracer.server.ts:236). The PR adds a dedicated test that constructs it, which should catch this, but it relies on the test environment resolving the same export shape as the running server.Was this helpful? React with 👍 or 👎 to provide feedback.