Skip to content

Commit 6e5f0f0

Browse files
authored
fix(webapp,clickhouse): stop invalid customer queries alerting, and isolate Sentry scope per request (#4372)
## Summary A query sent to the query API with a typo in it, like a column name that does not exist, was being reported as a server error. That put customer SQL mistakes into our error alerting, where they made up almost all of the volume on one of our noisiest alerts, and it drowned out the failures that are actually ours to fix. This makes the level match who is at fault, and fixes two related problems found alongside it. ## Invalid queries are the caller's, not ours The query API route already got this right. It checks for `QueryError`, logs at warn, and returns a 400, with a comment saying the system handles it gracefully and no alert is needed. The layer underneath ignored that. `executeTSQL` logged every exception out of its catch block at error, including the compile failures the route was about to turn into a 400, and error-level logs are forwarded to error reporting. The TSQL package already draws the line we need: ```ts export class ExposedTSQLError extends BaseTSQLError { /** An exception that can be exposed to the user. */ } export class InternalTSQLError extends BaseTSQLError { /** An internal exception in the TSQL engine. */ } ``` `SyntaxError` and `QueryError` extend the first. So the catch block now branches on `ExposedTSQLError` and logs those at warn, keeping error for `InternalTSQLError` and anything unanticipated, which is a genuine compiler bug. ## SQL the caller wrote is their mistake, not ours The same asymmetry showed up one level down. A query that compiles fine can still be rejected by ClickHouse at execution, and most of those rejections mean the caller's SQL is wrong rather than that we generated something bad. This is where the volume actually is. Checking production, one error group alone, a missing `GROUP BY` on the public query API (`NOT_AN_AGGREGATE`), accounts for over a million events across hundreds of users. It is by far the largest error group in the project, and classifying only by resource limit would have left every one of those at error level. So rejections are split three ways in `ClickhouseClient`, which is the only place holding the parsed `ClickHouseError` and its symbolic type. By the time the error reaches `executeTSQL` it has been wrapped and the type is gone, and the type never appears in the message text, so it cannot be recovered by string matching. - **Resource limits** (memory ceiling, timeout, row/byte caps) log at warn. The query is valid, it just asked for more than it is allowed to spend. - **Invalid SQL** (`NOT_AN_AGGREGATE`, `UNKNOWN_IDENTIFIER`, `SYNTAX_ERROR`, the type and parse families) logs at warn **only when the caller wrote the SQL**. - **Everything else** keeps alerting. That gate matters. The client is shared, so the identical rejection on TRQL *we* generated is our bug and has to stay at error. Callers opt in with `userAuthoredQuery`: | caller | who wrote the SQL | opts in | | --- | --- | --- | | public query API | the customer | yes | | query editor | the customer | yes | | agent charts | the agent's model | yes | | built-in dashboard tiles | us, in code | no | | queue metric cards | us, in code | no | | health report | us, in code | no | The agent is the one judgement call. Its TRQL is not typed by a person, but it is also not something a code fix makes correct, so a query it gets wrong is not worth waking anyone for. The same endpoint serves built-in tiles whose TRQL we do write, so the opt-in lives with the caller rather than the route. Separately, when one of these queries did fail, the log recorded the generated ClickHouse SQL but not the query the caller actually wrote, which made the reports hard to act on. `queryWithStats` takes an optional `logFields` that `executeTSQL` uses to attach the original TSQL. ## Events were attributed to the wrong request Chasing the above turned up something broader: only a tenth of the events on that alert pointed at the query API. The rest were pinned to unrelated requests that happened to be in flight at the same time, so the alert looked like the trigger endpoint was failing. `Sentry.init` runs with `skipOpenTelemetrySetup: true`, because we register our own OTel pipeline. That skips `initOpenTelemetry`, and one of the things it does is: ```js api.context.setGlobalContextManager(new SentryContextManager()); ``` The async-context strategy is still installed, but `withIsolationScope` only marks the OTel context and delegates the actual fork to that context manager: ```js // "We depend on the otelContextManager to handle the context/hub" return api.context.with(ctx.setValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY, true), ...) ``` `provider.register()` installed a plain `AsyncLocalStorageContextManager`, which does not know that key. The lookup found no scopes on the context and fell back to the process-global default isolation scope, so every request wrote its request data into the same object and the last writer won. The tracer now registers `SentryContextManager`, which subclasses `AsyncLocalStorageContextManager`, so OTel behaviour is unchanged. It is also registered on the path where tracing is disabled, which previously never called `register()` at all and so had no context manager of its own. Tenant tags were always correct, because those come from our own async local storage rather than the isolation scope. That is why the attribution being wrong was not obvious. This affects every error report the webapp sends, not just the query API. ## Verification `internal-packages/clickhouse`: 76 tests pass, including eight covering each level decision against a real ClickHouse container. Three pairs pin the gate open and shut at both layers: an invalid query, a compile failure, and a real limit breach driven with `max_rows_to_read` each log at warn with `userAuthoredQuery` and at error without it. The isolation fix has a test that reproduces the leak before asserting the fix. Two overlapping requests each tag their own isolation scope; with the plain context manager the slower one reads back the other's tag, and with `SentryContextManager` each reads back its own. Measured separately against a faithful reproduction of the server's wiring (own OTel pipeline, CommonJS entry) at 200 concurrent requests: per-request attribution goes from 0.5% to 100%, while span nesting, context propagation across awaits, and distinct trace IDs are identical before and after.
1 parent 2f1734c commit 6e5f0f0

13 files changed

Lines changed: 446 additions & 12 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Fixed error reports being attributed to the wrong request when several requests were in flight at once.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
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.

apps/webapp/app/components/dashboard-agent/AgentChart.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export function AgentChart({ block }: { block: ChartBlock }) {
6767
period: block.period ?? null,
6868
from: null,
6969
to: null,
70+
userAuthoredQuery: true,
7071
}),
7172
signal: controller.signal,
7273
})

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
183183
const queryResult = await executeQuery({
184184
name: "query-page",
185185
query,
186+
userAuthoredQuery: true,
186187
scope,
187188
organizationId: project.organizationId,
188189
projectId: project.id,

apps/webapp/app/routes/api.v1.query.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const { action, loader } = createActionApiRoute(
5454
const queryResult = await executeQuery({
5555
name: "api-query",
5656
query,
57+
userAuthoredQuery: true,
5758
scope: scope as QueryScope,
5859
organizationId: env.organization.id,
5960
projectId: env.project.id,

apps/webapp/app/routes/resources.metric.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const MetricWidgetQuery = z.object({
5252
tags: z.array(z.string()).optional(),
5353
// Opt into server-side gap fill (carry-forward for gauges, zero-fill for counters).
5454
fillGaps: z.boolean().optional(),
55+
userAuthoredQuery: z.boolean().optional(),
5556
});
5657

5758
export const action = async ({ request }: ActionFunctionArgs) => {
@@ -88,6 +89,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
8889
providers,
8990
tags: _tags,
9091
fillGaps,
92+
userAuthoredQuery,
9193
} = submission.data;
9294

9395
// Check they should be able to access it
@@ -126,6 +128,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
126128
operations,
127129
providers,
128130
fillGaps,
131+
userAuthoredQuery,
129132
// Set higher concurrency if many widgets are on screen at once
130133
customOrgConcurrencyLimit: env.METRIC_WIDGET_DEFAULT_ORG_CONCURRENCY_LIMIT,
131134
});

apps/webapp/app/services/queryService.server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ export type ExecuteQueryOptions<TOut extends z.ZodSchema> = Omit<
100100
};
101101
/** Custom per-org concurrency limit (overrides default) */
102102
customOrgConcurrencyLimit?: number;
103+
/**
104+
* Set when the caller wrote `query` themselves, as on the public query API and
105+
* the query editor. ClickHouse rejecting their SQL is then their mistake, so it
106+
* is logged as a warning instead of raising an alert. Leave unset for TRQL we
107+
* generate, where the same rejection is a bug worth alerting on.
108+
*/
109+
userAuthoredQuery?: boolean;
103110
};
104111

105112
/**

apps/webapp/app/v3/tracer.server.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
type Attributes,
33
type Context,
4+
context as otelContext,
45
createContextKey,
56
DiagConsoleLogger,
67
DiagLogLevel,
@@ -14,6 +15,7 @@ import {
1415
metrics,
1516
type Meter,
1617
} from "@opentelemetry/api";
18+
import sentryRemix from "@sentry/remix";
1719
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
1820
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
1921
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
@@ -209,10 +211,32 @@ function getResource() {
209211
return baseResource.merge(detectedResource);
210212
}
211213

214+
/**
215+
* Sentry's `withIsolationScope` only marks the OTel context; the fork itself is
216+
* done by Sentry's context manager. We pass `skipOpenTelemetrySetup: true` to
217+
* `Sentry.init` because we run our own OTel pipeline, which also skips the
218+
* `setGlobalContextManager(new SentryContextManager())` that Sentry would
219+
* otherwise do. Registering it here is what keeps per-request scopes (and so
220+
* the request attributed to each Sentry event) from leaking between concurrent
221+
* requests. It extends `AsyncLocalStorageContextManager`, so OTel behaviour is
222+
* unchanged.
223+
*
224+
* Reached through the default export because `@sentry/remix` is CommonJS and
225+
* Node's ESM loader does not detect this transitively re-exported name, so a
226+
* named import resolves at build time and then fails when the server boots.
227+
*/
228+
function createContextManager() {
229+
return new sentryRemix.SentryContextManager();
230+
}
231+
212232
function setupTelemetry() {
213233
if (env.INTERNAL_OTEL_TRACE_DISABLED === "1") {
214234
console.log(`🔦 Tracer disabled, returning a noop tracer`);
215235

236+
const contextManager = createContextManager();
237+
contextManager.enable();
238+
otelContext.setGlobalContextManager(contextManager);
239+
216240
return {
217241
tracer: trace.getTracer("trigger.dev", "3.3.12"),
218242
logger: logs.getLogger("trigger.dev", "3.3.12"),
@@ -300,7 +324,7 @@ function setupTelemetry() {
300324
);
301325
}
302326

303-
provider.register();
327+
provider.register({ contextManager: createContextManager() });
304328

305329
let instrumentations: Instrumentation[] = [
306330
new AwsSdkInstrumentation({
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { context } from "@opentelemetry/api";
2+
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
3+
import * as Sentry from "@sentry/remix";
4+
import sentryRemix from "@sentry/remix";
5+
import { afterEach, beforeAll, describe, expect, it } from "vitest";
6+
7+
/**
8+
* Two overlapping requests, each tagging its own isolation scope, mirroring what
9+
* `SentryHttpInstrumentation` does per incoming request. Returns what each one
10+
* reads back after the other has started.
11+
*/
12+
async function raceTwoRequests(): Promise<Record<string, unknown>> {
13+
const observed: Record<string, unknown> = {};
14+
15+
const handleRequest = (name: string, holdMs: number) =>
16+
Sentry.withIsolationScope(async () => {
17+
Sentry.getIsolationScope().setTag("request", name);
18+
await new Promise((resolve) => setTimeout(resolve, holdMs));
19+
observed[name] = Sentry.getIsolationScope().getScopeData().tags.request;
20+
});
21+
22+
await Promise.all([handleRequest("slow", 30), handleRequest("fast", 5)]);
23+
24+
return observed;
25+
}
26+
27+
describe("Sentry request isolation", () => {
28+
beforeAll(() => {
29+
Sentry.init({ dsn: undefined, defaultIntegrations: false, skipOpenTelemetrySetup: true });
30+
});
31+
32+
afterEach(() => {
33+
context.disable();
34+
});
35+
36+
it("leaks the isolation scope between concurrent requests without SentryContextManager", async () => {
37+
new NodeTracerProvider().register();
38+
39+
const observed = await raceTwoRequests();
40+
41+
expect(observed).toEqual({ slow: "fast", fast: "fast" });
42+
});
43+
44+
it("keeps each request's isolation scope separate with SentryContextManager", async () => {
45+
new NodeTracerProvider().register({ contextManager: new sentryRemix.SentryContextManager() });
46+
47+
const observed = await raceTwoRequests();
48+
49+
expect(observed).toEqual({ slow: "slow", fast: "fast" });
50+
});
51+
});

internal-packages/clickhouse/src/client/client.ts

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -171,13 +171,15 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter {
171171
);
172172

173173
if (clickhouseError) {
174-
this.logger.error("Error querying clickhouse", {
174+
const errorLogFields = {
175175
name: req.name,
176176
error: clickhouseError,
177177
query: req.query,
178178
params,
179179
queryId,
180-
});
180+
};
181+
182+
this.logger.error("Error querying clickhouse", errorLogFields);
181183

182184
recordClickhouseError(span, clickhouseError);
183185

@@ -260,6 +262,16 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter {
260262
* These will be merged with the default settings.
261263
*/
262264
settings?: ClickHouseSettings;
265+
/**
266+
* Extra fields to attach to the error log if the query fails. Use this to
267+
* record what produced the SQL, e.g. the TSQL a caller actually wrote.
268+
*/
269+
logFields?: Record<string, unknown>;
270+
/**
271+
* Set when the SQL originates from whoever made the request rather than
272+
* from us. Invalid-SQL rejections are then their mistake, not a bug.
273+
*/
274+
userAuthoredQuery?: boolean;
263275
}): ClickhouseQueryWithStatsFunction<z.input<TIn>, z.output<TOut>> {
264276
return async (params, options) => {
265277
const queryId = randomUUID();
@@ -320,13 +332,25 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter {
320332
);
321333

322334
if (clickhouseError) {
323-
this.logger.error("Error querying clickhouse", {
335+
const errorLogFields = {
336+
...req.logFields,
324337
name: req.name,
325338
error: clickhouseError,
326339
query: req.query,
327340
params,
328341
queryId,
329-
});
342+
};
343+
344+
switch (classifyClickhouseError(clickhouseError, req.userAuthoredQuery)) {
345+
case "quota":
346+
this.logger.warn("Query exceeded a ClickHouse limit", errorLogFields);
347+
break;
348+
case "invalid-sql":
349+
this.logger.warn("ClickHouse rejected an invalid query", errorLogFields);
350+
break;
351+
default:
352+
this.logger.error("Error querying clickhouse", errorLogFields);
353+
}
330354

331355
recordClickhouseError(span, clickhouseError);
332356

@@ -453,13 +477,15 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter {
453477
);
454478

455479
if (clickhouseError) {
456-
this.logger.error("Error querying clickhouse", {
480+
const errorLogFields = {
457481
name: req.name,
458482
error: clickhouseError,
459483
query: req.query,
460484
params,
461485
queryId,
462-
});
486+
};
487+
488+
this.logger.error("Error querying clickhouse", errorLogFields);
463489

464490
recordClickhouseError(span, clickhouseError);
465491

@@ -599,13 +625,15 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter {
599625

600626
span.setAttributes({ "clickhouse.rows": rowCount });
601627
} catch (error) {
602-
self.logger.error("Error streaming clickhouse", {
628+
const errorLogFields = {
603629
name: req.name,
604630
error,
605631
query: req.query,
606632
params,
607633
queryId,
608-
});
634+
};
635+
636+
self.logger.error("Error streaming clickhouse", errorLogFields);
609637

610638
if (error instanceof Error) {
611639
recordClickhouseError(span, error);
@@ -1001,6 +1029,65 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter {
10011029
}
10021030
}
10031031

1032+
/**
1033+
* ClickHouse error types raised by a query that is valid but asks for more than
1034+
* it is allowed to spend. Only downgraded for SQL the caller wrote: a runaway
1035+
* query we generated is our bug and still has to alert.
1036+
*/
1037+
const CLICKHOUSE_QUOTA_ERROR_TYPES = new Set([
1038+
"MEMORY_LIMIT_EXCEEDED",
1039+
"TIMEOUT_EXCEEDED",
1040+
"TOO_SLOW",
1041+
"TOO_MANY_ROWS",
1042+
"TOO_MANY_BYTES",
1043+
"TOO_MANY_ROWS_OR_BYTES",
1044+
]);
1045+
1046+
/**
1047+
* ClickHouse error types that mean the SQL itself is wrong. Only treated as the
1048+
* caller's fault when the query was written by the caller — the same error on a
1049+
* query we generated is our bug and has to keep alerting.
1050+
*/
1051+
const CLICKHOUSE_INVALID_SQL_ERROR_TYPES = new Set([
1052+
"NOT_AN_AGGREGATE",
1053+
"ILLEGAL_AGGREGATION",
1054+
"UNKNOWN_IDENTIFIER",
1055+
"UNKNOWN_FUNCTION",
1056+
"UNKNOWN_TABLE",
1057+
"AMBIGUOUS_COLUMN_NAME",
1058+
"MULTIPLE_EXPRESSIONS_FOR_ALIAS",
1059+
"SYNTAX_ERROR",
1060+
"BAD_ARGUMENTS",
1061+
"TYPE_MISMATCH",
1062+
"NO_COMMON_TYPE",
1063+
"ILLEGAL_TYPE_OF_ARGUMENT",
1064+
"ILLEGAL_COLUMN",
1065+
"CANNOT_CONVERT_TYPE",
1066+
"CANNOT_PARSE_TEXT",
1067+
"CANNOT_PARSE_NUMBER",
1068+
"CANNOT_PARSE_DATE",
1069+
"CANNOT_PARSE_DATETIME",
1070+
"CANNOT_PARSE_INPUT_ASSERTION_FAILED",
1071+
]);
1072+
1073+
type ClickhouseErrorCategory = "quota" | "invalid-sql" | "fault";
1074+
1075+
function classifyClickhouseError(
1076+
error: Error,
1077+
userAuthoredQuery: boolean | undefined
1078+
): ClickhouseErrorCategory {
1079+
if (!userAuthoredQuery || !(error instanceof ClickHouseError) || error.type === undefined) {
1080+
return "fault";
1081+
}
1082+
if (CLICKHOUSE_QUOTA_ERROR_TYPES.has(error.type)) {
1083+
return "quota";
1084+
}
1085+
if (CLICKHOUSE_INVALID_SQL_ERROR_TYPES.has(error.type)) {
1086+
return "invalid-sql";
1087+
}
1088+
return "fault";
1089+
}
1090+
10041091
function recordClickhouseError(span: Span, error: Error): void {
10051092
if (error instanceof ClickHouseError) {
10061093
span.setAttributes({

0 commit comments

Comments
 (0)