From bfc18d93a8715cabec2119098f764ecf90721296 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 7 Sep 2026 18:38:22 +0200 Subject: [PATCH] test(cloudflare): Port the binding suites to span streaming Removes the `traceLifecycle: 'static'` pin from `suites/d1`, `suites/r2`, `suites/queue`, `suites/prisma`, `suites/durableobject/error`, `suites/workflows/step-context` and `suites/vite/diagnostics-channel/vercelai-6`, and rewrites the assertions from transaction envelopes to span v2. `durableobject/error` and `workflows/step-context` only assert on error events, so the pin is all that goes. `cache-client` and `durableobject-scope` were already unpinned; they drop `transaction` from their ignore list, which is now an envelope type nothing emits. The binding spans keep the shape they had, and only the encoding changes: a span carries its name in `name` rather than `description`, its op and origin as attributes, and every attribute as a `{ type, value }` pair. The r2 and queue suites therefore keep one envelope expectation per request. Two things do change. D1 and Prisma spans are `db.query`, so streaming names them after `db.query.summary`. `SELECT * FROM users WHERE id = ?` becomes `SELECT users`. The Prisma suite loses the description-based split between its two `SELECT` spans, which now share the name `SELECT main.User`, so the D1 one is picked by its op and the traceparent comment is asserted on `db.query.text` instead. `prisma` and `vercelai-6` collect until the whole trace is in hand, seventeen spans and three. Both assert on the complete child set of one request, and the span buffer flushes on a timer, so reading a single envelope would be a race. Waiting for the segment span would be one too: it ends last, but each envelope is its own request to the mock server, so it can arrive before its children do. `vercelai-6` also drops the separate span container it used to read next to the transaction item: a streamed gen_ai span is an ordinary item of the one span envelope. Co-Authored-By: Claude Opus 5 (1M context) --- .../suites/cache-client/test.ts | 8 +- .../suites/d1/index.ts | 1 - .../suites/d1/test.ts | 193 ++++++------ .../suites/durableobject-scope/test.ts | 6 +- .../suites/durableobject/error/index.ts | 2 - .../suites/prisma/index.ts | 1 - .../suites/prisma/test.ts | 119 ++++---- .../suites/queue/index.ts | 1 - .../suites/queue/test.ts | 120 +++----- .../suites/r2/index.ts | 1 - .../suites/r2/test.ts | 281 +++++++----------- .../diagnostics-channel/vercelai-6/index.ts | 1 - .../diagnostics-channel/vercelai-6/test.ts | 87 +++--- .../suites/workflows/step-context/index.ts | 2 - 14 files changed, 342 insertions(+), 481 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/cache-client/test.ts b/dev-packages/cloudflare-integration-tests/suites/cache-client/test.ts index 23f2e3962e6d..451890dfe782 100644 --- a/dev-packages/cloudflare-integration-tests/suites/cache-client/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/cache-client/test.ts @@ -110,7 +110,7 @@ it('cacheClient: true - detached work events ARE captured', async ({ signal }) = }); it('cacheClient: false - repro #22545: detached work events are silently dropped', async ({ signal }) => { - const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); + const runner = createRunner(__dirname).ignore('span').start(signal); // Make the request that spawns detached work await runner.makeRequest('get', '/no-cache/detached?id=repro-1'); @@ -135,7 +135,7 @@ it('cacheClient: true - dedupe drops the same error across invocations', async ( // A shared client shares its dedupe state, so the same error captured by two separate // invocations is reported only once — the second is dropped as a duplicate. const runner = createRunner(__dirname) - .ignore('transaction', 'span') + .ignore('span') .unordered() .failOnUnexpected() .expect(errorEventExpectation('Same error', CAPTURE_MECHANISM)) @@ -159,7 +159,7 @@ it('cacheClient: true - dedupe drops the same error across invocations', async ( it('cacheClient: false - dedupe does not persist across invocations', async ({ signal }) => { // A fresh client per invocation means fresh dedupe state, so each invocation reports // the same error independently. - const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); + const runner = createRunner(__dirname).ignore('span').start(signal); for (let i = 0; i < 3; i++) { await runner.makeRequestAndWaitForEnvelope( @@ -174,7 +174,7 @@ it('cacheClient: false - dedupe does not persist across invocations', async ({ s // also start reusing the isolation scope `setTag`/`setUser` write to. The uncached counterpart of // this test lives in the `durable-object-scope` suite. it('cacheClient: true - two consecutive invocations get different isolation scopes', async ({ signal }) => { - const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); + const runner = createRunner(__dirname).ignore('span').start(signal); await runner.makeRequestAndWaitForEnvelope('get', '/cache/scope?id=scope-shared&seed=1', (envelope: Envelope) => { const event = envelope[1]?.[0]?.[1] as Event; diff --git a/dev-packages/cloudflare-integration-tests/suites/d1/index.ts b/dev-packages/cloudflare-integration-tests/suites/d1/index.ts index 115fffd92545..640ebc649121 100644 --- a/dev-packages/cloudflare-integration-tests/suites/d1/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/d1/index.ts @@ -9,7 +9,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/d1/test.ts index 3f4912bc17f5..ab28f8c80d80 100644 --- a/dev-packages/cloudflare-integration-tests/suites/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/d1/test.ts @@ -1,54 +1,37 @@ -import type { Envelope } from '@sentry/core'; +import type { Envelope, SerializedStreamedSpan } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../runner'; +import { getSpansFromEnvelope } from '../../spanUtils'; -function envelopeItemType(envelope: Envelope): string | undefined { - return envelope[1][0]?.[0]?.type as string | undefined; -} - -function envelopeItem(envelope: Envelope): Record { - return envelope[1][0]![1] as Record; -} - -function findD1Spans(envelope: Envelope): Array> { - if (envelopeItemType(envelope) !== 'transaction') return []; - const tx = envelopeItem(envelope); - const spans = (tx.spans as Array>) || []; - return spans.filter(s => (s.op as string) === 'db.query'); -} +// `cloudflare.d1.duration` is only an integer when the query happens to take a whole number of +// milliseconds, so the type can't be pinned down. +const NUMBER_ATTRIBUTE = { type: expect.stringMatching(/^(?:integer|double)$/), value: expect.any(Number) }; it('instruments D1 prepare().all() automatically via env', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect((envelope: Envelope) => { - expect(envelopeItemType(envelope)).toBe('transaction'); - const d1Spans = findD1Spans(envelope); - expect(d1Spans.length).toBeGreaterThanOrEqual(1); - - const querySpan = d1Spans.find(s => s.description === 'SELECT * FROM users WHERE id = ?'); - expect(querySpan).toBeDefined(); - expect(querySpan).toEqual({ - data: { - 'db.system.name': 'cloudflare-d1', - 'db.operation.name': 'all', - 'db.query.text': 'SELECT * FROM users WHERE id = ?', - 'db.query.summary': 'SELECT users', - 'cloudflare.d1.duration': expect.any(Number), - 'cloudflare.d1.rows_read': expect.any(Number), - 'cloudflare.d1.rows_written': expect.any(Number), - 'sentry.op': 'db.query', - 'sentry.origin': 'auto.db.cloudflare.d1', - }, - description: 'SELECT * FROM users WHERE id = ?', - op: 'db.query', - origin: 'auto.db.cloudflare.d1', - status: 'ok', - parent_span_id: expect.any(String), - span_id: expect.any(String), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.any(String), - }); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + + // The D1 span is named after its query summary rather than the full query text. + const querySpan = spans.find(span => span.attributes['db.operation.name']?.value === 'all'); + expect(querySpan?.name).toBe('SELECT users'); + expect(querySpan?.parent_span_id).toBe(segmentSpan?.span_id); + expect(querySpan?.status).toBe('ok'); + expect(querySpan?.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'db.query' }, + 'sentry.origin': { type: 'string', value: 'auto.db.cloudflare.d1' }, + 'db.system.name': { type: 'string', value: 'cloudflare-d1' }, + 'db.operation.name': { type: 'string', value: 'all' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM users WHERE id = ?' }, + 'db.query.summary': { type: 'string', value: 'SELECT users' }, + 'cloudflare.d1.duration': NUMBER_ATTRIBUTE, + 'cloudflare.d1.rows_read': NUMBER_ATTRIBUTE, + 'cloudflare.d1.rows_written': NUMBER_ATTRIBUTE, + }), + ); }) .start(signal); @@ -58,10 +41,10 @@ it('instruments D1 prepare().all() automatically via env', async ({ signal }) => it('captures error event when a D1 query references a non-existent table', async ({ signal }) => { const runner = createRunner(__dirname) - .ignore('transaction') + .ignore('span') .expect((envelope: Envelope) => { - expect(envelopeItemType(envelope)).toBe('event'); - const event = envelopeItem(envelope); + expect(envelope[1][0]?.[0]?.type).toBe('event'); + const event = envelope[1][0]![1] as Record; expect(event.level).toBe('error'); const values = (event.exception as { values: Array> })?.values; @@ -102,32 +85,26 @@ it('instruments D1 exec() automatically via env', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect((envelope: Envelope) => { - expect(envelopeItemType(envelope)).toBe('transaction'); - const d1Spans = findD1Spans(envelope); - - const execSpan = d1Spans.find( - s => s.description === 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + + const execSpan = spans.find(span => span.attributes['db.operation.name']?.value === 'exec'); + expect(execSpan?.name).toBe('CREATE TABLE users'); + expect(execSpan?.parent_span_id).toBe(segmentSpan?.span_id); + expect(execSpan?.status).toBe('ok'); + expect(execSpan?.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'db.query' }, + 'sentry.origin': { type: 'string', value: 'auto.db.cloudflare.d1' }, + 'db.system.name': { type: 'string', value: 'cloudflare-d1' }, + 'db.operation.name': { type: 'string', value: 'exec' }, + 'db.query.text': { + type: 'string', + value: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + }, + 'db.query.summary': { type: 'string', value: 'CREATE TABLE users' }, + }), ); - expect(execSpan).toBeDefined(); - expect(execSpan).toEqual({ - data: { - 'db.system.name': 'cloudflare-d1', - 'db.operation.name': 'exec', - 'db.query.text': 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', - 'db.query.summary': 'CREATE TABLE users', - 'sentry.op': 'db.query', - 'sentry.origin': 'auto.db.cloudflare.d1', - }, - description: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', - op: 'db.query', - origin: 'auto.db.cloudflare.d1', - status: 'ok', - parent_span_id: expect.any(String), - span_id: expect.any(String), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.any(String), - }); }) .start(signal); @@ -136,20 +113,30 @@ it('instruments D1 exec() automatically via env', async ({ signal }) => { }); it('instruments D1 withSession().batch() identically to db.batch()', async ({ signal }) => { - let directBatchSpan: Record | undefined; - let sessionBatchSpan: Record | undefined; + let directBatchSpan: SerializedStreamedSpan | undefined; + let sessionBatchSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .ignore('event') .expect((envelope: Envelope) => { - expect(envelopeItem(envelope).transaction).toBe('GET /batch'); + const spans = getSpansFromEnvelope(envelope); + // Both routes are raw URLs, so the streamed segment name keeps the method only and the + // request is identified through `url.path`. + expect(spans.find(span => span.is_segment)?.attributes['url.path']).toEqual({ + type: 'string', + value: '/batch', + }); - directBatchSpan = findD1Spans(envelope).find(s => s.description === 'D1 batch'); + directBatchSpan = spans.find(span => span.name === 'D1 batch'); }) .expect((envelope: Envelope) => { - expect(envelopeItem(envelope).transaction).toBe('GET /with-session/batch'); + const spans = getSpansFromEnvelope(envelope); + expect(spans.find(span => span.is_segment)?.attributes['url.path']).toEqual({ + type: 'string', + value: '/with-session/batch', + }); - sessionBatchSpan = findD1Spans(envelope).find(s => s.description === 'D1 batch'); + sessionBatchSpan = spans.find(span => span.name === 'D1 batch'); }) .unordered() .start(signal); @@ -161,16 +148,19 @@ it('instruments D1 withSession().batch() identically to db.batch()', async ({ si expect(directBatchSpan).toBeDefined(); expect(sessionBatchSpan).toBeDefined(); - const normalize = (span: Record): Record => { + // Ids and timestamps differ between the two requests, everything else must match. + const normalize = (span: SerializedStreamedSpan): Record => { const { span_id: _spanId, parent_span_id: _parentSpanId, start_timestamp: _start, - timestamp: _end, + end_timestamp: _end, trace_id: _traceId, + attributes, ...rest } = span; - return rest; + const { 'sentry.segment.id': _segmentId, ...restAttributes } = attributes; + return { ...rest, attributes: restAttributes }; }; expect(normalize(sessionBatchSpan!)).toEqual(normalize(directBatchSpan!)); @@ -180,30 +170,25 @@ it('instruments D1 batch() automatically via env', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect((envelope: Envelope) => { - expect(envelopeItemType(envelope)).toBe('transaction'); - const d1Spans = findD1Spans(envelope); - - const batchSpan = d1Spans.find(s => s.description === 'D1 batch'); - expect(batchSpan).toBeDefined(); - expect(batchSpan).toEqual({ - data: { - 'db.system.name': 'cloudflare-d1', - 'db.operation.name': 'batch', - 'db.query.text': 'INSERT INTO users (name) VALUES (?)\nINSERT INTO users (name) VALUES (?)', - 'db.operation.batch.size': 2, - 'sentry.op': 'db.query', - 'sentry.origin': 'auto.db.cloudflare.d1', - }, - description: 'D1 batch', - op: 'db.query', - origin: 'auto.db.cloudflare.d1', - status: 'ok', - parent_span_id: expect.any(String), - span_id: expect.any(String), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.any(String), - }); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + + const batchSpan = spans.find(span => span.name === 'D1 batch'); + expect(batchSpan?.parent_span_id).toBe(segmentSpan?.span_id); + expect(batchSpan?.status).toBe('ok'); + expect(batchSpan?.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'db.query' }, + 'sentry.origin': { type: 'string', value: 'auto.db.cloudflare.d1' }, + 'db.system.name': { type: 'string', value: 'cloudflare-d1' }, + 'db.operation.name': { type: 'string', value: 'batch' }, + 'db.query.text': { + type: 'string', + value: 'INSERT INTO users (name) VALUES (?)\nINSERT INTO users (name) VALUES (?)', + }, + 'db.operation.batch.size': { type: 'integer', value: 2 }, + }), + ); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject-scope/test.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject-scope/test.ts index 5da3d134d60a..b99aeda4d302 100644 --- a/dev-packages/cloudflare-integration-tests/suites/durableobject-scope/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject-scope/test.ts @@ -3,7 +3,7 @@ import { expect, it } from 'vitest'; import { createRunner } from '../../runner'; it('two consecutive invocations get different isolation scopes', async ({ signal }) => { - const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); + const runner = createRunner(__dirname).ignore('span').start(signal); await runner.makeRequestAndWaitForEnvelope('get', '/scope?seed=1', (envelope: Envelope) => { const event = envelope[1]?.[0]?.[1] as Event; @@ -23,7 +23,7 @@ it('two consecutive invocations get different isolation scopes', async ({ signal }); it('a nested direct call within one invocation shares the same isolation scope', async ({ signal }) => { - const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); + const runner = createRunner(__dirname).ignore('span').start(signal); await runner.makeRequestAndWaitForEnvelope('get', '/nested', (envelope: Envelope) => { const event = envelope[1]?.[0]?.[1] as Event; @@ -51,7 +51,7 @@ it('a nested direct call within one invocation shares the same isolation scope', }); it('a nested call into another instrumented handler shares the same isolation scope', async ({ signal }) => { - const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); + const runner = createRunner(__dirname).ignore('span').start(signal); await runner.makeRequestAndWaitForEnvelope('get', '/reentrant', (envelope: Envelope) => { const event = envelope[1]?.[0]?.[1] as Event; diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/error/index.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/error/index.ts index c2a1ba92b8ce..8415c2dac61a 100644 --- a/dev-packages/cloudflare-integration-tests/suites/durableobject/error/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/error/index.ts @@ -19,7 +19,6 @@ class TestDurableObjectBase extends DurableObject { export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), TestDurableObjectBase, @@ -28,7 +27,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/prisma/index.ts b/dev-packages/cloudflare-integration-tests/suites/prisma/index.ts index 64663e9e1838..6975b274370e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/prisma/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/prisma/index.ts @@ -11,7 +11,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1, integrations: [Sentry.prismaIntegration()], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/prisma/test.ts b/dev-packages/cloudflare-integration-tests/suites/prisma/test.ts index 586792d669c5..00cc9cd45cf0 100644 --- a/dev-packages/cloudflare-integration-tests/suites/prisma/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/prisma/test.ts @@ -1,8 +1,8 @@ import { execSync } from 'node:child_process'; import { join } from 'node:path'; -import type { Envelope } from '@sentry/core'; import { beforeAll, expect, it } from 'vitest'; import { createRunner } from '../../runner'; +import { getSpanOp } from '../../spanUtils'; beforeAll(() => { // Generate the Prisma client (including the WASM query engine used on Workers) before wrangler @@ -10,76 +10,63 @@ beforeAll(() => { execSync(`yarn prisma generate --schema ${join(__dirname, 'schema.prisma')}`, { cwd: __dirname, stdio: 'inherit' }); }, 120_000); -function envelopeItemType(envelope: Envelope): string | undefined { - return envelope[1][0]?.[0]?.type as string | undefined; -} +it('captures Prisma spans for a D1 query via the @sentry/cloudflare prismaIntegration', async ({ signal }) => { + const runner = createRunner(__dirname).start(signal); -function envelopeItem(envelope: Envelope): Record { - return envelope[1][0]![1] as Record; -} + // The request produces one segment span and sixteen children. Waiting for all seventeen rather + // than for the segment: it ends last, but each envelope is its own request to the mock server, so + // it can arrive before the envelope carrying the children this test asserts on. + const spansPromise = runner.collectStreamedSpans(spansOfTrace => spansOfTrace.length === 17); -it('captures a transaction with Prisma spans for a D1 query via the @sentry/cloudflare prismaIntegration', async ({ - signal, -}) => { - const runner = createRunner(__dirname) - .ignore('event') - .expect((envelope: Envelope) => { - expect(envelopeItemType(envelope)).toBe('transaction'); - - const transaction = envelopeItem(envelope); - const trace = (transaction.contexts as Record> | undefined)?.trace; + await runner.makeRequest('get', '/users'); - expect(transaction.transaction).toBe('GET /users'); - expect(trace?.op).toBe('http.server'); - expect(trace?.origin).toBe('auto.http.cloudflare'); - expect(trace?.status).toBe('ok'); + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment); - const createUserTableQuery = - 'CREATE TABLE IF NOT EXISTS User (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT)'; - const selectUsersQuery = - 'SELECT `main`.`User`.`id`, `main`.`User`.`email`, `main`.`User`.`name` FROM `main`.`User` WHERE 1=1 LIMIT ? OFFSET ?'; + // `/users` is a raw URL, so the streamed segment name keeps the method only. + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/users' }); + expect(segmentSpan?.status).toBe('ok'); - // Only the span shape is stable here - ids, timestamps, and parent ids are not. - const spans = ((transaction.spans as Array>) || []).map(span => ({ - description: span.description, - op: span.op, - origin: span.origin, - })); + // Only the span shape is stable here - ids, timestamps, and parent ids are not. + const childSpans = spans + .filter(span => !span.is_segment) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })); - expect(spans).toHaveLength(16); - expect(spans).toEqual( - expect.arrayContaining([ - { - description: createUserTableQuery, - op: 'db.query', - origin: 'auto.db.cloudflare.d1', - }, - { - description: expect.stringMatching( - /^SELECT `main`\.`User`\.`id`, `main`\.`User`\.`email`, `main`\.`User`\.`name` FROM `main`\.`User` WHERE 1=1 LIMIT \? OFFSET \? \/\* traceparent='00-[\da-f]{32}-[\da-f]{16}-01' \*\/$/, - ), - op: 'db.query', - origin: 'auto.db.cloudflare.d1', - }, - { description: 'prisma:client:connect', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:client:load_engine', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:client:operation', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:client:serialize', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:engine:connect', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:engine:connection', op: 'db', origin: 'auto.db.prisma' }, - { description: 'prisma:engine:query', op: undefined, origin: 'auto.db.prisma' }, - { description: selectUsersQuery, op: 'db', origin: 'auto.db.prisma' }, - { description: 'prisma:engine:js:query:args', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:engine:js:query:sql', op: 'db', origin: 'auto.db.prisma' }, - { description: 'prisma:engine:js:query:result', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:engine:serialize', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:engine:response_json_serialization', op: undefined, origin: 'auto.db.prisma' }, - ]), - ); - expect(spans.filter(span => span.description === 'prisma:engine:connection')).toHaveLength(2); - }) - .start(signal); + expect(childSpans).toHaveLength(16); + expect(childSpans).toEqual( + expect.arrayContaining([ + // Both D1 queries are named after their query summary rather than the full query text. + { name: 'CREATE TABLE User', op: 'db.query', origin: 'auto.db.cloudflare.d1' }, + { name: 'SELECT `main`.`User`', op: 'db.query', origin: 'auto.db.cloudflare.d1' }, + { name: 'prisma:client:connect', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:client:load_engine', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:client:operation', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:client:serialize', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:engine:connect', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:engine:connection', op: 'db', origin: 'auto.db.prisma' }, + { name: 'prisma:engine:query', op: undefined, origin: 'auto.db.prisma' }, + { name: 'SELECT `main`.`User`', op: 'db', origin: 'auto.db.prisma' }, + { name: 'prisma:engine:js:query:args', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:engine:js:query:sql', op: 'db', origin: 'auto.db.prisma' }, + { name: 'prisma:engine:js:query:result', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:engine:serialize', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:engine:response_json_serialization', op: undefined, origin: 'auto.db.prisma' }, + ]), + ); + expect(childSpans.filter(span => span.name === 'prisma:engine:connection')).toHaveLength(2); - await runner.makeRequest('get', '/users'); - await runner.completed(); + // The Prisma query reaches D1 with the trace context appended as a SQL comment. The two + // `SELECT` spans share a name, so the D1 one is picked by its op. + expect( + spans.find(span => getSpanOp(span) === 'db.query' && span.name === 'SELECT `main`.`User`')?.attributes[ + 'db.query.text' + ], + ).toEqual({ + type: 'string', + value: expect.stringMatching( + /^SELECT `main`\.`User`\.`id`, `main`\.`User`\.`email`, `main`\.`User`\.`name` FROM `main`\.`User` WHERE 1=1 LIMIT \? OFFSET \? \/\* traceparent='00-[\da-f]{32}-[\da-f]{16}-01' \*\/$/, + ), + }); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/queue/index.ts b/dev-packages/cloudflare-integration-tests/suites/queue/index.ts index 405b86695ed4..e06560ccf690 100644 --- a/dev-packages/cloudflare-integration-tests/suites/queue/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/queue/index.ts @@ -9,7 +9,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/queue/test.ts b/dev-packages/cloudflare-integration-tests/suites/queue/test.ts index f0886ba3f37c..5424850fda1a 100644 --- a/dev-packages/cloudflare-integration-tests/suites/queue/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/queue/test.ts @@ -1,35 +1,14 @@ import type { Envelope } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../runner'; - -function envelopeItemType(envelope: Envelope): string | undefined { - return envelope[1][0]?.[0]?.type as string | undefined; -} - -function envelopeItem(envelope: Envelope): Record { - return envelope[1][0]![1] as Record; -} - -function findPublishSpan(envelope: Envelope): Record | undefined { - if (envelopeItemType(envelope) !== 'transaction') return undefined; - const tx = envelopeItem(envelope); - const spans = (tx.spans as Array>) || []; - return spans.find(s => (s.op as string) === 'queue.publish'); -} - -function isConsumerTransaction(envelope: Envelope): boolean { - if (envelopeItemType(envelope) !== 'transaction') return false; - const tx = envelopeItem(envelope); - return tx.transaction === 'process test-queue'; -} +import { getSpansFromEnvelope } from '../../spanUtils'; it('captures errors thrown by the queue handler with the correct mechanism', async ({ signal }) => { const runner = createRunner(__dirname) - .ignore('transaction') + .ignore('span') .expect((envelope: Envelope) => { - expect(envelopeItemType(envelope)).toBe('event'); - const event = envelopeItem(envelope); - expect(event).toMatchObject({ + expect(envelope[1][0]?.[0]?.type).toBe('event'); + expect(envelope[1][0]?.[1]).toMatchObject({ level: 'error', exception: { values: [ @@ -48,43 +27,44 @@ it('captures errors thrown by the queue handler with the correct mechanism', asy await runner.completed(); }); -it('emits a queue.publish span on env.MY_QUEUE.send and a queue.process transaction on the consumer', async ({ +it('emits a queue.publish span on env.MY_QUEUE.send and a queue.process segment span on the consumer', async ({ signal, }) => { const runner = createRunner(__dirname) .unordered() .expect((envelope: Envelope) => { - // Producer transaction must contain a queue.publish child span - const publishSpan = findPublishSpan(envelope); + // The producer trace carries a `queue.publish` child span. + const publishSpan = getSpansFromEnvelope(envelope).find(span => span.name === 'send MY_QUEUE'); + expect(publishSpan).toBeDefined(); - expect(publishSpan).toMatchObject({ - op: 'queue.publish', - description: 'send MY_QUEUE', - data: expect.objectContaining({ - 'messaging.system': 'cloudflare', - 'messaging.destination.name': 'MY_QUEUE', - 'messaging.operation.type': 'send', - 'messaging.operation.name': 'send', - 'sentry.origin': 'auto.faas.cloudflare.queue', + expect(publishSpan?.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.queue' }, + 'messaging.system': { type: 'string', value: 'cloudflare' }, + 'messaging.destination.name': { type: 'string', value: 'MY_QUEUE' }, + 'messaging.operation.type': { type: 'string', value: 'send' }, + 'messaging.operation.name': { type: 'string', value: 'send' }, }), - }); + ); }) .expect((envelope: Envelope) => { - expect(isConsumerTransaction(envelope)).toBe(true); - const tx = envelopeItem(envelope); - const trace = (tx.contexts as Record>).trace as Record; - expect(trace).toMatchObject({ - op: 'queue.process', - origin: 'auto.faas.cloudflare.queue', - data: expect.objectContaining({ - 'messaging.system': 'cloudflare', - 'messaging.destination.name': 'test-queue', - 'messaging.operation.type': 'process', - 'messaging.operation.name': 'process', - 'messaging.batch.message_count': 1, - 'faas.trigger': 'pubsub', + // The consumer runs in its own trace, so its segment span arrives in its own envelope. + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('process test-queue'); + expect(segmentSpan?.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'queue.process' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.queue' }, + 'messaging.system': { type: 'string', value: 'cloudflare' }, + 'messaging.destination.name': { type: 'string', value: 'test-queue' }, + 'messaging.operation.type': { type: 'string', value: 'process' }, + 'messaging.operation.name': { type: 'string', value: 'process' }, + 'messaging.batch.message_count': { type: 'integer', value: 1 }, + 'faas.trigger': { type: 'string', value: 'pubsub' }, }), - }); + ); }) .start(signal); @@ -96,30 +76,26 @@ it('emits a queue.publish span with batch attributes on env.MY_QUEUE.sendBatch', const runner = createRunner(__dirname) .unordered() .expect((envelope: Envelope) => { - const publishSpan = findPublishSpan(envelope); + const publishSpan = getSpansFromEnvelope(envelope).find(span => span.name === 'send MY_QUEUE'); + expect(publishSpan).toBeDefined(); - expect(publishSpan).toMatchObject({ - op: 'queue.publish', - description: 'send MY_QUEUE', - data: expect.objectContaining({ - 'messaging.system': 'cloudflare', - 'messaging.destination.name': 'MY_QUEUE', - 'messaging.operation.type': 'send', - 'messaging.operation.name': 'send', - 'messaging.batch.message_count': 3, - 'sentry.origin': 'auto.faas.cloudflare.queue', + expect(publishSpan?.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.queue' }, + 'messaging.system': { type: 'string', value: 'cloudflare' }, + 'messaging.destination.name': { type: 'string', value: 'MY_QUEUE' }, + 'messaging.operation.type': { type: 'string', value: 'send' }, + 'messaging.operation.name': { type: 'string', value: 'send' }, + 'messaging.batch.message_count': { type: 'integer', value: 3 }, }), - }); + ); }) .expect((envelope: Envelope) => { - expect(isConsumerTransaction(envelope)).toBe(true); - const tx = envelopeItem(envelope); - const trace = (tx.contexts as Record>).trace as Record; - expect(trace).toMatchObject({ - data: expect.objectContaining({ - 'messaging.batch.message_count': 3, - }), - }); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('process test-queue'); + expect(segmentSpan?.attributes['messaging.batch.message_count']).toEqual({ type: 'integer', value: 3 }); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/r2/index.ts b/dev-packages/cloudflare-integration-tests/suites/r2/index.ts index 67341b3b8114..7f1da7308dba 100644 --- a/dev-packages/cloudflare-integration-tests/suites/r2/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/r2/index.ts @@ -9,7 +9,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/r2/test.ts b/dev-packages/cloudflare-integration-tests/suites/r2/test.ts index 689419c44b89..0b94c55f8bfc 100644 --- a/dev-packages/cloudflare-integration-tests/suites/r2/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/r2/test.ts @@ -1,66 +1,35 @@ -import type { Envelope } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../runner'; - -function envelopeItemType(envelope: Envelope): string | undefined { - return envelope[1][0]?.[0]?.type as string | undefined; -} - -function envelopeItem(envelope: Envelope): Record { - return envelope[1][0]![1] as Record; -} - -function findSpans(envelope: Envelope, description: string): Array> { - if (envelopeItemType(envelope) !== 'transaction') return []; - const tx = envelopeItem(envelope); - const spans = (tx.spans as Array>) || []; - return spans.filter(s => s.description === description); -} - -function spanData(span: Record): Record { - return span.data as Record; -} +import { getSpansFromEnvelope } from '../../spanUtils'; it('emits r2_put and r2_get spans with correct attributes', async ({ signal }) => { const runner = createRunner(__dirname) - .expect((envelope: Envelope) => { - const putSpans = findSpans(envelope, 'r2_put'); - expect(putSpans).toHaveLength(1); - const putData = spanData(putSpans[0]!); - expect({ - op: putSpans[0]!.op, - description: putSpans[0]!.description, - 'cloudflare.r2.operation': putData['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': putData['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': putData['cloudflare.r2.request.key'], - 'sentry.origin': putData['sentry.origin'], - }).toEqual({ - op: 'object.put', - description: 'r2_put', - 'cloudflare.r2.operation': 'PutObject', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': 'test-key.txt', - 'sentry.origin': 'auto.faas.cloudflare.r2', - }); - - const getSpans = findSpans(envelope, 'r2_get'); - expect(getSpans).toHaveLength(1); - const getData = spanData(getSpans[0]!); - expect({ - op: getSpans[0]!.op, - description: getSpans[0]!.description, - 'cloudflare.r2.operation': getData['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': getData['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': getData['cloudflare.r2.request.key'], - 'sentry.origin': getData['sentry.origin'], - }).toEqual({ - op: 'object.get', - description: 'r2_get', - 'cloudflare.r2.operation': 'GetObject', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': 'test-key.txt', - 'sentry.origin': 'auto.faas.cloudflare.r2', - }); + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + + expect(spans.filter(span => span.name === 'r2_put')).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.put' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'PutObject' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + 'cloudflare.r2.request.key': { type: 'string', value: 'test-key.txt' }, + }), + }), + ]); + + expect(spans.filter(span => span.name === 'r2_get')).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.get' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'GetObject' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + 'cloudflare.r2.request.key': { type: 'string', value: 'test-key.txt' }, + }), + }), + ]); }) .start(signal); @@ -70,25 +39,18 @@ it('emits r2_put and r2_get spans with correct attributes', async ({ signal }) = it('emits an r2_head span', async ({ signal }) => { const runner = createRunner(__dirname) - .expect((envelope: Envelope) => { - const spans = findSpans(envelope, 'r2_head'); - expect(spans).toHaveLength(1); - const data = spanData(spans[0]!); - expect({ - op: spans[0]!.op, - description: spans[0]!.description, - 'cloudflare.r2.operation': data['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': data['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': data['cloudflare.r2.request.key'], - 'sentry.origin': data['sentry.origin'], - }).toEqual({ - op: 'object.head', - description: 'r2_head', - 'cloudflare.r2.operation': 'HeadObject', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': 'head-key.txt', - 'sentry.origin': 'auto.faas.cloudflare.r2', - }); + .expect(envelope => { + expect(getSpansFromEnvelope(envelope).filter(span => span.name === 'r2_head')).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.head' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'HeadObject' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + 'cloudflare.r2.request.key': { type: 'string', value: 'head-key.txt' }, + }), + }), + ]); }) .start(signal); @@ -98,25 +60,20 @@ it('emits an r2_head span', async ({ signal }) => { it('emits an r2_list span without a key attribute', async ({ signal }) => { const runner = createRunner(__dirname) - .expect((envelope: Envelope) => { - const spans = findSpans(envelope, 'r2_list'); - expect(spans).toHaveLength(1); - const data = spanData(spans[0]!); - expect({ - op: spans[0]!.op, - description: spans[0]!.description, - 'cloudflare.r2.operation': data['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': data['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': data['cloudflare.r2.request.key'], - 'sentry.origin': data['sentry.origin'], - }).toEqual({ - op: 'object.list', - description: 'r2_list', - 'cloudflare.r2.operation': 'ListObjects', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': undefined, - 'sentry.origin': 'auto.faas.cloudflare.r2', - }); + .expect(envelope => { + const listSpans = getSpansFromEnvelope(envelope).filter(span => span.name === 'r2_list'); + + expect(listSpans).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.list' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'ListObjects' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + }), + }), + ]); + expect(listSpans[0]?.attributes['cloudflare.r2.request.key']).toBeUndefined(); }) .start(signal); @@ -126,25 +83,18 @@ it('emits an r2_list span without a key attribute', async ({ signal }) => { it('emits an r2_delete span', async ({ signal }) => { const runner = createRunner(__dirname) - .expect((envelope: Envelope) => { - const spans = findSpans(envelope, 'r2_delete'); - expect(spans).toHaveLength(1); - const data = spanData(spans[0]!); - expect({ - op: spans[0]!.op, - description: spans[0]!.description, - 'cloudflare.r2.operation': data['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': data['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': data['cloudflare.r2.request.key'], - 'sentry.origin': data['sentry.origin'], - }).toEqual({ - op: 'object.delete', - description: 'r2_delete', - 'cloudflare.r2.operation': 'DeleteObject', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': 'delete-me.txt', - 'sentry.origin': 'auto.faas.cloudflare.r2', - }); + .expect(envelope => { + expect(getSpansFromEnvelope(envelope).filter(span => span.name === 'r2_delete')).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.delete' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'DeleteObject' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + 'cloudflare.r2.request.key': { type: 'string', value: 'delete-me.txt' }, + }), + }), + ]); }) .start(signal); @@ -154,66 +104,51 @@ it('emits an r2_delete span', async ({ signal }) => { it('emits spans for each multipart upload operation', async ({ signal }) => { const runner = createRunner(__dirname) - .expect((envelope: Envelope) => { - const createSpans = findSpans(envelope, 'r2_createMultipartUpload'); - expect(createSpans).toHaveLength(1); - const createData = spanData(createSpans[0]!); - expect({ - op: createSpans[0]!.op, - description: createSpans[0]!.description, - 'cloudflare.r2.operation': createData['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': createData['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': createData['cloudflare.r2.request.key'], - 'sentry.origin': createData['sentry.origin'], - }).toEqual({ - op: 'object.multipart_upload.create', - description: 'r2_createMultipartUpload', - 'cloudflare.r2.operation': 'CreateMultipartUpload', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': 'multipart.bin', - 'sentry.origin': 'auto.faas.cloudflare.r2', - }); - - const uploadPartSpans = findSpans(envelope, 'r2_uploadPart'); + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + + expect(spans.filter(span => span.name === 'r2_createMultipartUpload')).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.multipart_upload.create' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'CreateMultipartUpload' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + 'cloudflare.r2.request.key': { type: 'string', value: 'multipart.bin' }, + }), + }), + ]); + + const uploadPartSpans = spans.filter(span => span.name === 'r2_uploadPart'); expect(uploadPartSpans).toHaveLength(2); - const part0Data = spanData(uploadPartSpans[0]!); - expect({ - op: uploadPartSpans[0]!.op, - description: uploadPartSpans[0]!.description, - 'cloudflare.r2.operation': part0Data['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': part0Data['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': part0Data['cloudflare.r2.request.key'], - 'cloudflare.r2.request.part_number': part0Data['cloudflare.r2.request.part_number'], - 'sentry.origin': part0Data['sentry.origin'], - }).toEqual({ - op: 'object.upload_part', - description: 'r2_uploadPart', - 'cloudflare.r2.operation': 'UploadPart', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': 'multipart.bin', - 'cloudflare.r2.request.part_number': 1, - 'sentry.origin': 'auto.faas.cloudflare.r2', - }); - expect(spanData(uploadPartSpans[1]!)['cloudflare.r2.request.part_number']).toBe(2); - - const completeSpans = findSpans(envelope, 'r2_completeMultipartUpload'); - expect(completeSpans).toHaveLength(1); - const completeData = spanData(completeSpans[0]!); - expect({ - op: completeSpans[0]!.op, - description: completeSpans[0]!.description, - 'cloudflare.r2.operation': completeData['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': completeData['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': completeData['cloudflare.r2.request.key'], - 'sentry.origin': completeData['sentry.origin'], - }).toEqual({ - op: 'object.multipart_upload.complete', - description: 'r2_completeMultipartUpload', - 'cloudflare.r2.operation': 'CompleteMultipartUpload', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': 'multipart.bin', - 'sentry.origin': 'auto.faas.cloudflare.r2', + expect(uploadPartSpans[0]).toEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.upload_part' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'UploadPart' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + 'cloudflare.r2.request.key': { type: 'string', value: 'multipart.bin' }, + 'cloudflare.r2.request.part_number': { type: 'integer', value: 1 }, + }), + }), + ); + expect(uploadPartSpans[1]?.attributes['cloudflare.r2.request.part_number']).toEqual({ + type: 'integer', + value: 2, }); + + expect(spans.filter(span => span.name === 'r2_completeMultipartUpload')).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.multipart_upload.complete' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'CompleteMultipartUpload' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + 'cloudflare.r2.request.key': { type: 'string', value: 'multipart.bin' }, + }), + }), + ]); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/index.ts index e251a1f66cf7..40d3e3bbceee 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/index.ts @@ -9,7 +9,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts index 556f5babbe80..3e886958978f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts @@ -7,57 +7,44 @@ import { } from '@sentry/conventions/attributes'; import { createRunner } from '../../../../runner'; -it('captures a transaction with Vercel AI v6 spans via @sentry/cloudflare vercelAIIntegration', async ({ signal }) => { - const runner = createRunner(__dirname) - .ignore('event') - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /'); +it('captures Vercel AI v6 spans via @sentry/cloudflare vercelAIIntegration', async ({ signal }) => { + const runner = createRunner(__dirname).ignore('event').start(signal); - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); - - expect(container.items).toHaveLength(2); - expect(container.items).toEqual( - expect.arrayContaining([ - { - trace_id: expect.any(String), - span_id: expect.any(String), - parent_span_id: expect.any(String), - name: 'invoke_agent', - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - is_segment: false, - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'gen_ai.invoke_agent' }, - 'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' }, - [GEN_AI_OPERATION_NAME]: { type: 'string', value: 'invoke_agent' }, - [GEN_AI_USAGE_INPUT_TOKENS]: { type: 'integer', value: 10 }, - [GEN_AI_USAGE_OUTPUT_TOKENS]: { type: 'integer', value: 20 }, - [GEN_AI_USAGE_TOTAL_TOKENS]: { type: 'integer', value: 30 }, - }), - }, - { - trace_id: expect.any(String), - span_id: expect.any(String), - parent_span_id: expect.any(String), - name: 'generate_content mock-model-id', - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - is_segment: false, - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'gen_ai.generate_content' }, - 'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' }, - [GEN_AI_OPERATION_NAME]: { type: 'string', value: 'generate_content' }, - }), - }, - ]), - ); - }) - .start(signal); + // The request produces one segment span and the two gen_ai children. Waiting for all three rather + // than for the segment: it ends last, but each envelope is its own request to the mock server, so + // it can arrive before the envelope carrying the children this test asserts on. + const spansPromise = runner.collectStreamedSpans(spansOfTrace => spansOfTrace.length === 3); await runner.makeRequest('get', '/'); - await runner.completed(); + + const spans = await spansPromise; + + expect(spans.filter(span => !span.is_segment)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'invoke_agent', + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'gen_ai.invoke_agent' }, + 'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' }, + [GEN_AI_OPERATION_NAME]: { type: 'string', value: 'invoke_agent' }, + [GEN_AI_USAGE_INPUT_TOKENS]: { type: 'integer', value: 10 }, + [GEN_AI_USAGE_OUTPUT_TOKENS]: { type: 'integer', value: 20 }, + [GEN_AI_USAGE_TOTAL_TOKENS]: { type: 'integer', value: 30 }, + }), + }), + expect.objectContaining({ + name: 'generate_content mock-model-id', + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'gen_ai.generate_content' }, + 'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' }, + [GEN_AI_OPERATION_NAME]: { type: 'string', value: 'generate_content' }, + }), + }), + ]), + ); + expect(spans.filter(span => !span.is_segment)).toHaveLength(2); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/index.ts b/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/index.ts index d82cea5ef733..442c8073cf92 100644 --- a/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/index.ts @@ -50,7 +50,6 @@ class StepContextTestWorkflowBase extends WorkflowEntrypoint ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', }), StepContextTestWorkflowBase, ); @@ -58,7 +57,6 @@ export const StepContextTestWorkflow = Sentry.instrumentWorkflowWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', }), { async fetch(request, env, _ctx) {