Skip to content

Commit 1818744

Browse files
andreiborzaclaude
andcommitted
fix(node): Capture Prisma v5 engine spans under the SentryTracerProvider
Prisma v5 engine spans (`prisma:engine:*`, which carry the SQL `db.statement`) were minted by hijacking the OTel SDK tracer's private `_idGenerator`. The SentryTracerProvider's tracer has no `_idGenerator`, so the shim bailed and dropped every engine span, leaving only the `prisma:client:*` spans. Replace the hack with a span registry: client spans register by their span id on `spanStart`, and each v5 engine span is created under the parent it references by id via `startInactiveSpan`. Engine spans whose parent hasn't been seen yet wait in a pending buffer until a later batch registers it, reproducing the flat `parent_span_id` regrouping the OTel SDK exporter used to do. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a9b926a commit 1818744

2 files changed

Lines changed: 73 additions & 88 deletions

File tree

  • dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5
  • packages/node/src/integrations/tracing/prisma

dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,7 @@ afterAll(() => {
55
cleanupChildProcesses();
66
});
77

8-
// TODO(provider): Prisma v5 engine spans (`prisma:engine:*`) are minted by Sentry's v5 compatibility
9-
// shim (`prismaIntegration`), which forces the engine-supplied span/trace IDs by overriding the OTel
10-
// SDK tracer's private `_idGenerator`. Under the SentryTracerProvider the global tracer is a
11-
// `SentryTracer`, which has no `_idGenerator`, so the shim bails out and drops every engine span,
12-
// leaving only the `prisma:client:*` spans. v6/v7 are unaffected (they create engine spans via core's
13-
// span APIs). Re-enable once the v5 shim can mint spans with explicit IDs under the provider.
14-
describe.skip('Prisma ORM v5 Tests', () => {
8+
describe('Prisma ORM v5 Tests', () => {
159
createEsmAndCjsTests(
1610
__dirname,
1711
'scenario.mjs',

packages/node/src/integrations/tracing/prisma/index.ts

Lines changed: 72 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import type { Link, Tracer } from '@opentelemetry/api';
2-
import { context, SpanKind, trace, TraceFlags } from '@opentelemetry/api';
31
import type { Instrumentation } from '@opentelemetry/instrumentation';
4-
import type { IdGenerator } from '@opentelemetry/sdk-trace-base';
5-
import { consoleSandbox, defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON } from '@sentry/core';
2+
import type { Span } from '@sentry/core';
3+
import {
4+
defineIntegration,
5+
LRUMap,
6+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
7+
SPAN_KIND,
8+
spanToJSON,
9+
startInactiveSpan,
10+
} from '@sentry/core';
611
import { generateInstrumentOnce } from '@sentry/node-core';
712
import { PrismaInstrumentation } from './vendored/instrumentation';
813
import type { PrismaV5TracingHelper } from './vendored/v5-tracing-helper';
@@ -49,9 +54,56 @@ function getPrismaTracingHelper(): unknown | undefined {
4954
return prismaTracingHelper;
5055
}
5156

52-
type TracerWithIdGenerator = Tracer & {
53-
_idGenerator?: IdGenerator;
54-
};
57+
// Prisma v5 dispatches engine spans one at a time and out of order (a child can arrive before its
58+
// parent), detached from any active span, with the parent referenced only by id — either a client
59+
// span (by its real Sentry span id, which Prisma learned via `getTraceParent`) or a sibling engine
60+
// span (by the engine's own id). The OTel SDK exporter coped with this by buffering every span of a
61+
// trace and regrouping by `parent_span_id` at flush. The SentryTracerProvider has no such buffer (it
62+
// assembles the transaction from the live `_children` tree), so the regrouping is reproduced here:
63+
// `prismaSpanRegistry` maps each span id to its created Sentry span, and an engine span whose parent
64+
// is not registered yet waits in `pendingEngineSpans` until a later batch registers it.
65+
const MAX_TRACKED_PRISMA_SPANS = 1000;
66+
const prismaSpanRegistry = new LRUMap<string, Span>(MAX_TRACKED_PRISMA_SPANS);
67+
const pendingEngineSpans: V5EngineSpan[] = [];
68+
69+
/** Register a span so v5 engine spans can later resolve it as a parent by the id Prisma reports it under. */
70+
function registerPrismaSpan(id: string, span: Span): void {
71+
prismaSpanRegistry.set(id, span);
72+
}
73+
74+
/**
75+
* Create every pending v5 engine span whose parent is now registered, repeating until no further span
76+
* resolves (so a child queued before its parent is created once the parent arrives in a later batch).
77+
* Each span is created under its resolved parent and registered by its engine id so its own children
78+
* can find it; origin, the `db_query` rename, `otel.kind` and `op` are backfilled by the
79+
* `spanStart`/`spanEnd` hooks, exactly as for v6/v7 engine spans.
80+
*/
81+
function createResolvedEngineSpans(): void {
82+
let createdSpan = true;
83+
while (createdSpan) {
84+
createdSpan = false;
85+
for (let i = pendingEngineSpans.length - 1; i >= 0; i--) {
86+
const engineSpan = pendingEngineSpans[i]!;
87+
const parentSpan = prismaSpanRegistry.get(engineSpan.parent_span_id);
88+
if (!parentSpan) {
89+
continue;
90+
}
91+
92+
const span = startInactiveSpan({
93+
name: engineSpan.name,
94+
attributes: engineSpan.attributes,
95+
kind: engineSpan.kind === 'client' ? SPAN_KIND.CLIENT : SPAN_KIND.INTERNAL,
96+
startTime: engineSpan.start_time,
97+
parentSpan,
98+
});
99+
registerPrismaSpan(engineSpan.span_id, span);
100+
span.end(engineSpan.end_time);
101+
102+
pendingEngineSpans.splice(i, 1);
103+
createdSpan = true;
104+
}
105+
}
106+
}
55107

56108
interface PrismaOptions {
57109
/**
@@ -73,87 +125,23 @@ class SentryPrismaInteropInstrumentation extends PrismaInstrumentation {
73125
super.enable();
74126

75127
// The PrismaIntegration (super class) defines a global variable `global["PRISMA_INSTRUMENTATION"]` when `enable()` is called. This global variable holds a "TracingHelper" which Prisma uses internally to create tracing data. It's their way of not depending on OTEL with their main package. The sucky thing is, prisma broke the interface of the tracing helper with the v6 major update. This means that if you use Prisma 5 with the v6 instrumentation (or vice versa) Prisma just blows up, because tries to call methods on the helper that no longer exist.
76-
// Because we actually want to use the v6 instrumentation and not blow up in Prisma 5 user's faces, what we're doing here is backfilling the v5 method (`createEngineSpan`) with a noop so that no longer crashes when it attempts to call that function.
128+
// Because we actually want to use the v6 instrumentation and not blow up in Prisma 5 user's faces, what we're doing here is backfilling the v5-only method (`createEngineSpan`) so it routes through the v6/v7 helper instead of crashing.
77129
const prismaTracingHelper = getPrismaTracingHelper();
78130

79131
if (isPrismaV6TracingHelper(prismaTracingHelper)) {
80-
// Inspired & adjusted from https://github.com/prisma/prisma/tree/5.22.0/packages/instrumentation
132+
// Queue this batch and create every engine span whose parent is now known. The previous approach
133+
// minted spans with the engine's exact ids by hijacking the OTel SDK tracer's private
134+
// `_idGenerator`, which doesn't exist on the SentryTracerProvider's tracer — so under the
135+
// provider every engine span was dropped. See `createResolvedEngineSpans` for the parent-by-id
136+
// resolution that replaces it.
81137
(prismaTracingHelper as CompatibilityLayerTraceHelper).createEngineSpan = (
82138
engineSpanEvent: V5EngineSpanEvent,
83139
) => {
84-
const tracer = trace.getTracer('prismaV5Compatibility') as TracerWithIdGenerator;
85-
86-
// Prisma v5 relies on being able to create spans with a specific span & trace ID
87-
// this is no longer possible in OTEL v2, there is no public API to do this anymore
88-
// So in order to kind of hack this possibility, we rely on the internal `_idGenerator` property
89-
// This is used to generate the random IDs, and we overwrite this temporarily to generate static IDs
90-
// This is flawed and may not work, e.g. if the code is bundled and the private property is renamed
91-
// in such cases, these spans will not be captured and some Prisma spans will be missing
92-
const initialIdGenerator = tracer._idGenerator;
93-
94-
if (!initialIdGenerator) {
95-
consoleSandbox(() => {
96-
// eslint-disable-next-line no-console
97-
console.warn(
98-
'[Sentry] Could not find _idGenerator on tracer, skipping Prisma v5 compatibility - some Prisma spans may be missing!',
99-
);
100-
});
101-
102-
return;
103-
}
104-
105-
try {
106-
engineSpanEvent.spans.forEach(engineSpan => {
107-
const kind = engineSpan.kind === 'client' ? SpanKind.CLIENT : SpanKind.INTERNAL;
108-
109-
const parentSpanId = engineSpan.parent_span_id;
110-
const spanId = engineSpan.span_id;
111-
const traceId = engineSpan.trace_id;
112-
113-
const links: Link[] | undefined = engineSpan.links?.map(link => {
114-
return {
115-
context: {
116-
traceId: link.trace_id,
117-
spanId: link.span_id,
118-
traceFlags: TraceFlags.SAMPLED,
119-
},
120-
};
121-
});
122-
123-
const ctx = trace.setSpanContext(context.active(), {
124-
traceId,
125-
spanId: parentSpanId,
126-
traceFlags: TraceFlags.SAMPLED,
127-
});
128-
129-
context.with(ctx, () => {
130-
const temporaryIdGenerator: IdGenerator = {
131-
generateTraceId: () => {
132-
return traceId;
133-
},
134-
generateSpanId: () => {
135-
return spanId;
136-
},
137-
};
138-
139-
tracer._idGenerator = temporaryIdGenerator;
140-
141-
const span = tracer.startSpan(engineSpan.name, {
142-
kind,
143-
links,
144-
startTime: engineSpan.start_time,
145-
attributes: engineSpan.attributes,
146-
});
147-
148-
span.end(engineSpan.end_time);
149-
150-
tracer._idGenerator = initialIdGenerator;
151-
});
152-
});
153-
} finally {
154-
// Ensure we always restore this at the end, even if something errors
155-
tracer._idGenerator = initialIdGenerator;
140+
pendingEngineSpans.push(...engineSpanEvent.spans);
141+
if (pendingEngineSpans.length > MAX_TRACKED_PRISMA_SPANS) {
142+
pendingEngineSpans.splice(0, pendingEngineSpans.length - MAX_TRACKED_PRISMA_SPANS);
156143
}
144+
createResolvedEngineSpans();
157145
};
158146
}
159147
}
@@ -197,6 +185,9 @@ export const prismaIntegration = defineIntegration((options?: PrismaOptions) =>
197185
const spanJSON = spanToJSON(span);
198186
if (spanJSON.description?.startsWith('prisma:')) {
199187
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.prisma');
188+
// Register the span so v5 engine spans (dispatched later, detached) can resolve it as a
189+
// parent by the id Prisma reported it under (the span's own id; see `createResolvedEngineSpans`).
190+
registerPrismaSpan(span.spanContext().spanId, span);
200191
}
201192

202193
// Make sure we use the query text as the span name, for ex. SELECT * FROM "User" WHERE "id" = $1.

0 commit comments

Comments
 (0)