diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts index 4bec21ba206b..1aad43f30de3 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts @@ -24,7 +24,6 @@ const mockFetch: typeof fetch = async () => export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, dataCollection: { genAI: { inputs: true, outputs: true } }, }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts index a02dc42ebd06..073c293ba9fe 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts @@ -12,8 +12,15 @@ import { GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, } from '@sentry/conventions/attributes'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // This test runs the `@anthropic-ai/sdk` on the Workers runtime (with a // canned fetch) to verify the instrumentation works end-to-end on Cloudflare, @@ -23,14 +30,14 @@ it('traces a basic message creation request with the anthropic SDK', async ({ si const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /'); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan?.name).toBe('GET /'); - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); - expect(container.items).toHaveLength(1); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); - expect(container.items[0]).toEqual({ + expect(genAiSpans[0]).toEqual({ trace_id: expect.any(String), span_id: expect.any(String), parent_span_id: expect.any(String), @@ -58,6 +65,12 @@ it('traces a basic message creation request with the anthropic SDK', async ({ si [GEN_AI_USAGE_INPUT_TOKENS]: { value: 10, type: 'integer' }, [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 15, type: 'integer' }, [GEN_AI_USAGE_TOTAL_TOKENS]: { value: 25, type: 'integer' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts index 5962c54cc0c1..bc47380e603f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts @@ -2,14 +2,13 @@ import * as Sentry from '@sentry/cloudflare'; interface Env { SENTRY_DSN: string; - STREAMED?: string; DB: D1Database; } export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: env.STREAMED === 'true' ? 'stream' : 'static', + tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts index 96a5628da921..b1ebe9b06797 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; +import { expect, it } from 'vitest'; +import type { Envelope, SerializedStreamedSpan } from '@sentry/core'; import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, @@ -14,213 +14,112 @@ import { SENTRY_TRACE_LIFECYCLE, } from '@sentry/conventions/attributes'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; -it('D1 database queries create spans with correct attributes', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'GET /init', - spans: [ - { - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', - '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', - }, - 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), - }, - { - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', - 'db.system.name': 'cloudflare-d1', - 'db.operation.name': 'run', - 'db.query.text': 'INSERT INTO users (name) VALUES (?)', - 'db.query.summary': 'INSERT users', - 'cloudflare.d1.duration': expect.any(Number), - 'cloudflare.d1.rows_read': expect.any(Number), - 'cloudflare.d1.rows_written': expect.any(Number), - }, - description: 'INSERT INTO users (name) VALUES (?)', - 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), - }, - ], - }), - ); - }) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'GET /query', - spans: [ - { - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', - 'db.system.name': 'cloudflare-d1', - 'db.operation.name': 'first', - 'db.query.text': 'SELECT * FROM users WHERE name = ?', - 'db.query.summary': 'SELECT users', - }, - description: 'SELECT * FROM users WHERE name = ?', - 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); - - await runner.makeRequest('get', '/init'); - await runner.makeRequest('get', '/query'); - await runner.completed(); -}); +/** The `db.query` spans of an envelope, paired with the segment span they belong to. */ +function getD1Spans(envelope: Envelope): { + segmentSpan: SerializedStreamedSpan; + d1Spans: SerializedStreamedSpan[]; +} { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan).toBeDefined(); -describe('with span streaming enabled', () => { - function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { - const spanItem = envelope[1].find(item => item[0].type === 'span'); - expect(spanItem).toBeDefined(); - return spanItem![1] as SerializedStreamedSpanContainer; - } + return { + segmentSpan: segmentSpan!, + d1Spans: spans.filter(span => getSpanOp(span) === 'db.query'), + }; +} - /** The `db.query` spans of an envelope, paired with the segment span they belong to. */ - function getD1Spans(envelope: Envelope): { - segmentSpan: SerializedStreamedSpanContainer['items'][number]; - d1Spans: SerializedStreamedSpanContainer['items']; - } { - const items = getSpanContainer(envelope).items; - const segmentSpan = items.find(item => item.is_segment); - expect(segmentSpan).toBeDefined(); +function commonAttributes(segmentSpan: SerializedStreamedSpan): SerializedStreamedSpan['attributes'] { + return { + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan.span_id }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentSpan.name }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'db.query' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.d1' }, + 'db.system.name': { type: 'string', value: 'cloudflare-d1' }, + }; +} - return { - segmentSpan: segmentSpan!, - d1Spans: items.filter(item => item.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value === 'db.query'), - }; - } +function commonSpanProps(segmentSpan: SerializedStreamedSpan): Record { + return { + is_segment: false, + parent_span_id: segmentSpan.span_id, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: segmentSpan.trace_id, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }; +} - function commonAttributes( - segmentSpan: SerializedStreamedSpanContainer['items'][number], - ): SerializedStreamedSpanContainer['items'][number]['attributes'] { - return { - [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, - [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, - [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan.span_id }, - [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentSpan.name }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'db.query' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.d1' }, - 'db.system.name': { type: 'string', value: 'cloudflare-d1' }, - }; - } +// `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) }; - function commonSpanProps(segmentSpan: SerializedStreamedSpanContainer['items'][number]): Record { - return { - is_segment: false, - parent_span_id: segmentSpan.span_id, - span_id: expect.stringMatching(/^[\da-f]{16}$/), - trace_id: segmentSpan.trace_id, - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - }; - } - - // `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('names D1 query spans after their query summary', async ({ signal }) => { - const runner = createRunner(__dirname) - .withWranglerArgs('--var', 'STREAMED:true') - .expect(envelope => { - const { segmentSpan, d1Spans } = getD1Spans(envelope); - // With span streaming, the server span name is low cardinality, so the request the - // envelope belongs to is only identifiable through `url.path`. - expect(segmentSpan.name).toBe('GET'); - expect(segmentSpan.attributes['url.path']).toEqual({ type: 'string', value: '/init' }); +it('names D1 query spans after their query summary', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const { segmentSpan, d1Spans } = getD1Spans(envelope); + // With span streaming, the server span name is low cardinality, so the request the + // envelope belongs to is only identifiable through `url.path`. + expect(segmentSpan.name).toBe('GET'); + expect(segmentSpan.attributes['url.path']).toEqual({ type: 'string', value: '/init' }); - expect(d1Spans).toEqual([ - { - name: 'CREATE TABLE users', - attributes: { - ...commonAttributes(segmentSpan), - '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(d1Spans).toEqual([ + { + name: 'CREATE TABLE users', + attributes: { + ...commonAttributes(segmentSpan), + '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)', }, - ...commonSpanProps(segmentSpan), + 'db.query.summary': { type: 'string', value: 'CREATE TABLE users' }, }, - { - name: 'INSERT users', - attributes: { - ...commonAttributes(segmentSpan), - 'db.operation.name': { type: 'string', value: 'run' }, - 'db.query.text': { type: 'string', value: 'INSERT INTO users (name) VALUES (?)' }, - 'db.query.summary': { type: 'string', value: 'INSERT users' }, - 'cloudflare.d1.duration': NUMBER_ATTRIBUTE, - 'cloudflare.d1.rows_read': NUMBER_ATTRIBUTE, - 'cloudflare.d1.rows_written': NUMBER_ATTRIBUTE, - }, - ...commonSpanProps(segmentSpan), + ...commonSpanProps(segmentSpan), + }, + { + name: 'INSERT users', + attributes: { + ...commonAttributes(segmentSpan), + 'db.operation.name': { type: 'string', value: 'run' }, + 'db.query.text': { type: 'string', value: 'INSERT INTO users (name) VALUES (?)' }, + 'db.query.summary': { type: 'string', value: 'INSERT users' }, + 'cloudflare.d1.duration': NUMBER_ATTRIBUTE, + 'cloudflare.d1.rows_read': NUMBER_ATTRIBUTE, + 'cloudflare.d1.rows_written': NUMBER_ATTRIBUTE, }, - ]); - }) - .expect(envelope => { - const { segmentSpan, d1Spans } = getD1Spans(envelope); - expect(segmentSpan.name).toBe('GET'); - expect(segmentSpan.attributes['url.path']).toEqual({ type: 'string', value: '/query' }); + ...commonSpanProps(segmentSpan), + }, + ]); + }) + .expect(envelope => { + const { segmentSpan, d1Spans } = getD1Spans(envelope); + expect(segmentSpan.name).toBe('GET'); + expect(segmentSpan.attributes['url.path']).toEqual({ type: 'string', value: '/query' }); - expect(d1Spans).toEqual([ - { - name: 'SELECT users', - attributes: { - ...commonAttributes(segmentSpan), - 'db.operation.name': { type: 'string', value: 'first' }, - 'db.query.text': { type: 'string', value: 'SELECT * FROM users WHERE name = ?' }, - 'db.query.summary': { type: 'string', value: 'SELECT users' }, - }, - ...commonSpanProps(segmentSpan), + expect(d1Spans).toEqual([ + { + name: 'SELECT users', + attributes: { + ...commonAttributes(segmentSpan), + 'db.operation.name': { type: 'string', value: 'first' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM users WHERE name = ?' }, + 'db.query.summary': { type: 'string', value: 'SELECT users' }, }, - ]); - }) - .start(signal); + ...commonSpanProps(segmentSpan), + }, + ]); + }) + .start(signal); - await runner.makeRequest('get', '/init'); - await runner.makeRequest('get', '/query'); - await runner.completed(); - }); + await runner.makeRequest('get', '/init'); + await runner.makeRequest('get', '/query'); + await runner.completed(); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts index 0b990a4bb7e4..2f914b401772 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts @@ -10,7 +10,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts index d0bfde77e4cc..f9916e5c03c7 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest'; import { eventEnvelope } from '../../../expect'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; it('omits the span name from the DSC for url-source spans when tracing is enabled', async ({ signal }) => { const runner = createRunner(__dirname) @@ -32,22 +33,18 @@ it('omits the span name from the DSC for url-source spans when tracing is enable { includeSamplingFields: true, includeSampleRand: true, includeTransaction: false }, ), ) - // Transaction event: proves we are NOT in TwP — the span is recorded with a `url` source and - // carries the name on the event itself, even though it is intentionally absent from the DSC. + // Span envelope: proves we are NOT in TwP. The segment span is recorded with a `url` source, + // and its own envelope header omits the transaction from the DSC as well. .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'GET /error', - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ 'sentry.segment.name.source': 'url' }), - }), - }), - }), + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(envelope[0]).toEqual( + expect.not.objectContaining({ trace: expect.objectContaining({ transaction: expect.anything() }) }), ); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.segment.name.source']?.value).toBe('url'); + // A `url` source is high cardinality, so the streamed span name keeps the method only. + expect(segmentSpan?.name).toBe('GET'); }) .unordered() .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/index.ts index 4b239a05ee13..4c3d3a70186b 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/index.ts @@ -25,7 +25,6 @@ class SyncAlarmDurableObjectBase extends DurableObject { export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), SyncAlarmDurableObjectBase, @@ -34,7 +33,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['TEST_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/test.ts index d328dd1ae7ed..b4377bf1e98c 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/test.ts @@ -1,51 +1,42 @@ import { expect, it } from 'vitest'; -import type { TransactionEvent } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; it('sync alarm links to the trace that scheduled it via sentry.previous_trace', async ({ signal }) => { - let setAlarmTransaction: TransactionEvent | undefined; - let alarmTransaction: TransactionEvent | undefined; + let setAlarmSpan: SerializedStreamedSpan | undefined; + let alarmSpan: SerializedStreamedSpan | undefined; const testId = Date.now().toString(); const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: expect.stringContaining('/set-alarm'), - }), - ); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + // `/set-alarm` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/set-alarm' }); }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'setAlarm', - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - }), - ); - setAlarmTransaction = transactionEvent; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('setAlarm'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); + setAlarmSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'alarm', - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'function', - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - }), - ); - alarmTransaction = transactionEvent; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('alarm'); + expect(getSpanOp(segmentSpan!)).toBe('function'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); + alarmSpan = segmentSpan; }) .unordered() .start(signal); @@ -56,12 +47,11 @@ it('sync alarm links to the trace that scheduled it via sentry.previous_trace', // This is the key assertion: even though the alarm handler is synchronous, // sentry.previous_trace should still be set because we await the linkPromise // before teardown in the sync path - const traceData = alarmTransaction!.contexts?.trace?.data as Record | undefined; - const previousTrace = traceData?.['sentry.previous_trace'] as string | undefined; + const previousTrace = alarmSpan?.attributes['sentry.previous_trace']?.value as string | undefined; expect(previousTrace).toBeDefined(); expect(previousTrace).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); const [linkedTraceId] = previousTrace!.split('-'); - expect(linkedTraceId).toBe(setAlarmTransaction!.contexts?.trace?.trace_id); + expect(linkedTraceId).toBe(setAlarmSpan?.trace_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts index b5318b8c32a2..ba53ce2f9737 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts @@ -23,7 +23,6 @@ class AlarmDurableObjectBase extends DurableObject { export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), AlarmDurableObjectBase, @@ -32,7 +31,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['TEST_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/test.ts index 9bcfa2b0cadc..df890661ad33 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/test.ts @@ -1,51 +1,42 @@ import { expect, it } from 'vitest'; -import type { TransactionEvent } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; it('alarm links to the trace that scheduled it via sentry.previous_trace', async ({ signal }) => { - let setAlarmTransaction: TransactionEvent | undefined; - let alarmTransaction: TransactionEvent | undefined; + let setAlarmSpan: SerializedStreamedSpan | undefined; + let alarmSpan: SerializedStreamedSpan | undefined; const testId = Date.now().toString(); const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: expect.stringContaining('/set-alarm'), - }), - ); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + // `/set-alarm` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/set-alarm' }); }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'setAlarm', - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - }), - ); - setAlarmTransaction = transactionEvent; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('setAlarm'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); + setAlarmSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'alarm', - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'function', - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - }), - ); - alarmTransaction = transactionEvent; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('alarm'); + expect(getSpanOp(segmentSpan!)).toBe('function'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); + alarmSpan = segmentSpan; }) .unordered() .start(signal); @@ -53,12 +44,11 @@ it('alarm links to the trace that scheduled it via sentry.previous_trace', async await runner.makeRequest('get', `/set-alarm?id=${testId}`); await runner.completed(); - const traceData = alarmTransaction!.contexts?.trace?.data as Record | undefined; - const previousTrace = traceData?.['sentry.previous_trace'] as string | undefined; + const previousTrace = alarmSpan?.attributes['sentry.previous_trace']?.value as string | undefined; expect(previousTrace).toBeDefined(); expect(previousTrace).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); const [linkedTraceId] = previousTrace!.split('-'); - expect(linkedTraceId).toBe(setAlarmTransaction!.contexts?.trace?.trace_id); + expect(linkedTraceId).toBe(setAlarmSpan?.trace_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts index deac1c937381..6781334ca1bb 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts @@ -35,7 +35,6 @@ class MyDurableObjectBase extends DurableObject { export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyDurableObjectBase, @@ -44,7 +43,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts index 576d14127e13..d42e5ed44191 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts @@ -1,8 +1,8 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; -// Regression for #23040 — a Durable Object using native private fields must stay functional when +// Regression for #23040. A Durable Object using native private fields must stay functional when // instrumented with Sentry. Native RPC dispatch (Durable Object facets, // the Agents SDK bootstrap) invokes prototype methods with the stored instance as the receiver, // so the instrumented instance must not be a Proxy: a Proxy does not carry the private-field @@ -12,34 +12,23 @@ it('keeps native private fields working when a prototype method is invoked with }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - transaction: 'bootstrap', - }), - ); + expect(segmentSpan?.name).toBe('bootstrap'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /prototype-dispatch', - }), - ); + // `/prototype-dispatch` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/prototype-dispatch' }); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); }) .unordered() .start(signal); @@ -53,37 +42,22 @@ it('keeps native private fields working when a prototype method is invoked with it('propagates trace and preserves the result for a regular RPC method call', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - data: expect.objectContaining({ - 'sentry.origin': 'auto.faas.cloudflare.durable_object', - }), - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - transaction: 'setName', - }), - ); + expect(segmentSpan?.name).toBe('setName'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /rpc/set-name', - }), - ); + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/rpc/set-name' }); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); }) .unordered() .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/index.ts index c155720c3ba6..14e0de4e5e16 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/index.ts @@ -30,7 +30,6 @@ class TestDurableObjectBase extends DurableObject { export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), TestDurableObjectBase, @@ -39,7 +38,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['TEST_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts index f8e8b61131a5..e3f78b8f7a0d 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts @@ -1,59 +1,42 @@ +import type { Envelope } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // Regression test for https://github.com/getsentry/sentry-javascript/issues/20030 // When a Durable Object method calls Sentry.startSpan multiple times, those spans -// must appear as children of the DO transaction. The first invocation always worked; +// must appear as children of the DO segment span. The first invocation always worked; // the second invocation on the same DO instance previously lost its child spans // because the client was disposed after the first call. it('sends child spans on repeated Durable Object calls', async ({ signal }) => { - function assertDoWorkEnvelope(envelope: unknown): void { - const transactionEvent = (envelope as any)[1]?.[0]?.[1]; + function assertDoWorkEnvelope(envelope: Envelope): void { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'doWork', - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - }), - ); + expect(segmentSpan?.name).toBe('doWork'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); // All 5 child spans should be present - expect(transactionEvent.spans).toHaveLength(5); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ description: 'task-1', op: 'task' }), - expect.objectContaining({ description: 'task-2', op: 'task' }), - expect.objectContaining({ description: 'task-3', op: 'task' }), - expect.objectContaining({ description: 'task-4', op: 'task' }), - expect.objectContaining({ description: 'task-5', op: 'task' }), - ]); + const taskSpans = spans.filter(span => getSpanOp(span) === 'task'); + expect(taskSpans).toHaveLength(5); + expect(taskSpans.map(span => span.name).sort()).toEqual(['task-1', 'task-2', 'task-3', 'task-4', 'task-5']); - // All child spans share the root trace_id - const rootTraceId = transactionEvent.contexts?.trace?.trace_id; - expect(rootTraceId).toBeDefined(); - for (const span of transactionEvent.spans) { - expect(span.trace_id).toBe(rootTraceId); + // All child spans share the segment trace_id + for (const span of taskSpans) { + expect(span.trace_id).toBe(segmentSpan?.trace_id); } } - function assertOuterRequestEnvelope(envelope: unknown): void { - const transactionEvent = (envelope as any)[1]?.[0]?.[1]; + function assertOuterRequestEnvelope(envelope: Envelope): void { + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'GET /', - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - origin: 'auto.http.cloudflare', - }), - }), - }), - ); + expect(segmentSpan?.name).toBe('GET /'); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); } const runner = createRunner(__dirname).start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/index.ts index 29d580ca714e..4c8706dfa68d 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/index.ts @@ -30,7 +30,6 @@ class SqlDurableObjectBase extends DurableObject { export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), SqlDurableObjectBase, @@ -39,7 +38,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/tracing/durableobject-sql/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts index 8e185df57558..e9feaa6215f4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts @@ -1,7 +1,8 @@ -import type { Envelope, TransactionEvent } from '@sentry/core'; +import type { Envelope } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; +import { getSpansFromEnvelope } from '../../../spanUtils'; const flushMarkerMatcher = (envelope: Envelope): void => { const [, items] = envelope; @@ -14,57 +15,53 @@ const flushMarkerMatcher = (envelope: Envelope): void => { it('instruments SQL exec operations on Durable Object storage', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent | undefined; - const spans = transactionEvent?.spans ?? []; + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'GET /exec', - }), - ); + // `/exec` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/exec' }); - const sqlSpans = spans.filter(s => s.origin === 'auto.db.cloudflare.durable_object.sql'); + const sqlSpans = spans.filter( + span => span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value === 'auto.db.cloudflare.durable_object.sql', + ); expect(sqlSpans).toHaveLength(3); expect(sqlSpans).toEqual( expect.arrayContaining([ expect.objectContaining({ - description: 'CREATE TABLE users', - op: 'db.query', - origin: 'auto.db.cloudflare.durable_object.sql', - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql', - 'db.system.name': 'cloudflare-durable-object-sql', - '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', - 'cloudflare.durable_object.query.bindings': 0, + name: 'CREATE TABLE users', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'db.query' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.durable_object.sql' }, + 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, + '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' }, + 'cloudflare.durable_object.query.bindings': { type: 'integer', value: 0 }, }), }), expect.objectContaining({ - description: 'INSERT users', - op: 'db.query', - origin: 'auto.db.cloudflare.durable_object.sql', - data: expect.objectContaining({ - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'exec', - 'db.query.text': 'INSERT INTO users (name) VALUES (?)', - 'db.query.summary': 'INSERT users', - 'cloudflare.durable_object.query.bindings': 1, + name: 'INSERT users', + attributes: expect.objectContaining({ + 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, + 'db.operation.name': { type: 'string', value: 'exec' }, + 'db.query.text': { type: 'string', value: 'INSERT INTO users (name) VALUES (?)' }, + 'db.query.summary': { type: 'string', value: 'INSERT users' }, + 'cloudflare.durable_object.query.bindings': { type: 'integer', value: 1 }, }), }), expect.objectContaining({ - description: 'SELECT users', - op: 'db.query', - origin: 'auto.db.cloudflare.durable_object.sql', - data: expect.objectContaining({ - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'exec', - 'db.query.text': 'SELECT * FROM users', - 'db.query.summary': 'SELECT users', - 'cloudflare.durable_object.query.bindings': 0, + name: 'SELECT users', + attributes: expect.objectContaining({ + 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, + 'db.operation.name': { type: 'string', value: 'exec' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM users' }, + 'db.query.summary': { type: 'string', value: 'SELECT users' }, + 'cloudflare.durable_object.query.bindings': { type: 'integer', value: 0 }, }), }), ]), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/index.ts index 5f4fb0dd8cd8..175283d26c39 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/index.ts @@ -24,7 +24,6 @@ class SyncKvDurableObjectBase extends DurableObject { export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), SyncKvDurableObjectBase, @@ -33,7 +32,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/tracing/durableobject-sync-kv/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts index 4a3064871f93..1051890b8f94 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts @@ -1,7 +1,8 @@ -import type { Envelope, TransactionEvent } from '@sentry/core'; +import type { Envelope } from '@sentry/core'; import { expect, it } from 'vitest'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; const flushMarkerMatcher = (envelope: Envelope): void => { const [, items] = envelope; @@ -15,55 +16,44 @@ it('instruments sync KV operations on Durable Object storage', async ({ signal } const runner = createRunner(__dirname) .unordered() .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent | undefined; - const spans = transactionEvent?.spans ?? []; + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'GET /', - }), - ); + expect(segmentSpan?.name).toBe('GET /'); + + const kvSpans = spans.filter(span => getSpanOp(span) === 'db'); - expect(spans).toHaveLength(4); - expect(spans).toEqual( + expect(kvSpans).toHaveLength(4); + expect(kvSpans).toEqual( expect.arrayContaining([ expect.objectContaining({ - description: 'durable_object_storage_kv_put', - op: 'db', - origin: 'auto.db.cloudflare.durable_object', - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object', - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'put', + name: 'durable_object_storage_kv_put', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'db' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.durable_object' }, + 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, + 'db.operation.name': { type: 'string', value: 'put' }, }), }), expect.objectContaining({ - description: 'durable_object_storage_kv_get', - op: 'db', - origin: 'auto.db.cloudflare.durable_object', - data: expect.objectContaining({ - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'get', + name: 'durable_object_storage_kv_get', + attributes: expect.objectContaining({ + 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, + 'db.operation.name': { type: 'string', value: 'get' }, }), }), expect.objectContaining({ - description: 'durable_object_storage_kv_list', - op: 'db', - origin: 'auto.db.cloudflare.durable_object', - data: expect.objectContaining({ - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'list', + name: 'durable_object_storage_kv_list', + attributes: expect.objectContaining({ + 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, + 'db.operation.name': { type: 'string', value: 'list' }, }), }), expect.objectContaining({ - description: 'durable_object_storage_kv_delete', - op: 'db', - origin: 'auto.db.cloudflare.durable_object', - data: expect.objectContaining({ - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'delete', + name: 'durable_object_storage_kv_delete', + attributes: expect.objectContaining({ + 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, + 'db.operation.name': { type: 'string', value: 'delete' }, }), }), ]), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts index 26af95ddcef1..21a3400f3feb 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts @@ -44,7 +44,6 @@ class TestDurableObjectBase extends DurableObject { export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), TestDurableObjectBase, @@ -53,7 +52,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['TEST_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts index 84d37a9da80e..f48e7a1f1f5a 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts @@ -1,26 +1,18 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; it('traces a durable object method', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - data: expect.objectContaining({ - 'sentry.op': 'rpc', - 'sentry.origin': 'auto.faas.cloudflare.durable_object', - }), - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - transaction: 'sayHello', - }), - ); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('sayHello'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); }) .unordered() .start(signal); @@ -34,21 +26,9 @@ it('handles consecutive RPC calls without throwing "RPC receiver does not implem signal, }) => { const runner = createRunner(__dirname) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'sayHello', - }), - ); - }) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'sayHello', - }), - ); + .expectN(2, envelope => { + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(segmentSpan?.name).toBe('sayHello'); }) .unordered() .start(signal); @@ -73,20 +53,12 @@ it('handles consecutive RPC calls without throwing "RPC receiver does not implem it('allows RPC methods to access private class fields', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'setGreeting', - }), - ); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(segmentSpan?.name).toBe('setGreeting'); }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'sayHello', - }), - ); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(segmentSpan?.name).toBe('sayHello'); }) .unordered() .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts index c1caea111147..79a3de5a428e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts @@ -42,7 +42,6 @@ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, dataCollection: { genAI: { inputs: true } }, }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts index f61735845f3e..c4d0fcfff1bd 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts @@ -18,8 +18,15 @@ import { GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, } from '@sentry/conventions/attributes'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // This test runs the `@google/genai` SDK on the Workers runtime (with a // canned global fetch) to verify the instrumentation works end-to-end on @@ -29,15 +36,15 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /'); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan?.name).toBe('GET /'); - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); - expect(container.items).toHaveLength(3); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(3); const byName = (name: string): SerializedStreamedSpan => - container.items.find((span: SerializedStreamedSpan) => span.name === name); + genAiSpans.find(span => span.name === name) as SerializedStreamedSpan; expect(byName('chat gemini-1.5-pro')).toEqual({ trace_id: expect.any(String), @@ -77,6 +84,12 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ [GEN_AI_USAGE_INPUT_TOKENS]: { value: 8, type: 'integer' }, [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 12, type: 'integer' }, [GEN_AI_USAGE_TOTAL_TOKENS]: { value: 20, type: 'integer' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); @@ -106,6 +119,12 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 12, type: 'integer' }, [GEN_AI_USAGE_TOTAL_TOKENS]: { value: 20, type: 'integer' }, [GEN_AI_RESPONSE_TEXT]: { value: 'Hello from Google GenAI!', type: 'string' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); @@ -125,6 +144,12 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ [GEN_AI_OPERATION_NAME]: { value: 'embeddings', type: 'string' }, [GEN_AI_REQUEST_MODEL]: { value: 'text-embedding-004', type: 'string' }, [GEN_AI_EMBEDDINGS_INPUT]: { value: 'Hello world', type: 'string' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts index f0016eaf507b..973f54053571 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts @@ -8,7 +8,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', propagateTraceparent: true, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts index d24257f17cf2..57d10efa3b31 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts @@ -22,7 +22,6 @@ class EchoHeadersDurableObjectBase extends DurableObject { export const EchoHeadersDurableObject = instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), EchoHeadersDurableObjectBase, @@ -31,7 +30,6 @@ export const EchoHeadersDurableObject = instrumentDurableObjectWithSentry( export default withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['ECHO_HEADERS_DO'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts index ae38568e34ab..53a5651214b9 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; import { createRunner } from '../../../runner'; +import type { SerializedStreamedSpan } from '@sentry/core'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; type EchoedHeaders = { sentryTrace: string | null; @@ -13,56 +14,26 @@ type EchoedHeaders = { const SENTRY_TRACE_HEADER_RE = /^[0-9a-f]{32}-[0-9a-f]{16}-[01]$/; -type ScenarioPath = '/via-init' | '/via-request' | '/via-request-and-init' | '/with-preset-sentry-baggage'; - -function startStubFetchScenario(path: ScenarioPath, signal: AbortSignal) { - let mainTraceId: string | undefined; - let mainSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; - - const traceBase = { - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }; +function startStubFetchScenario(signal: AbortSignal) { + let mainSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; + // The worker and the Durable Object stream their spans from separate isolates, so each one + // arrives in its own envelope. const { makeRequest, completed } = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - const parentSpanId = transactionEvent.contexts?.trace?.parent_span_id; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining(traceBase), - }), - transaction: `GET ${path}`, - }), - ); - expect(parentSpanId).toBeUndefined(); - - mainTraceId = transactionEvent.contexts?.trace?.trace_id as string; - mainSpanId = transactionEvent.contexts?.trace?.span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.parent_span_id).toBeUndefined(); + mainSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - const parentSpanId = transactionEvent.contexts?.trace?.parent_span_id; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining(traceBase), - }), - transaction: `GET ${path}`, - }), - ); - expect(parentSpanId).toBeDefined(); - - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = parentSpanId as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.parent_span_id).toBeDefined(); + doSpan = segmentSpan; }) .unordered() .start(signal); @@ -71,18 +42,20 @@ function startStubFetchScenario(path: ScenarioPath, signal: AbortSignal) { makeRequest, async completedWithTraceCheck(): Promise { await completed(); - expect(mainTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(mainTraceId).toBe(doTraceId); - expect(mainSpanId).toBeDefined(); - expect(doParentSpanId).toBeDefined(); - expect(doParentSpanId).toBe(mainSpanId); + + // Both routes are raw URLs, so the streamed segment name keeps the method only. + expect(mainSpan?.name).toBe('GET'); + expect(doSpan?.name).toBe('GET'); + expect(mainSpan?.attributes['sentry.origin']?.value).toBe('auto.http.cloudflare'); + expect(doSpan?.attributes['sentry.origin']?.value).toBe('auto.http.cloudflare'); + expect(doSpan?.trace_id).toBe(mainSpan?.trace_id); + expect(doSpan?.parent_span_id).toBe(mainSpan?.span_id); }, }; } it('stub.fetch: headers in init (URL string + init)', async ({ signal }) => { - const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-init', signal); + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario(signal); const body = await makeRequest('get', '/via-init'); await completedWithTraceCheck(); @@ -95,7 +68,7 @@ it('stub.fetch: headers in init (URL string + init)', async ({ signal }) => { }); it('stub.fetch: headers on Request (URL from incoming request)', async ({ signal }) => { - const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-request', signal); + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario(signal); const body = await makeRequest('get', '/via-request'); await completedWithTraceCheck(); @@ -108,7 +81,7 @@ it('stub.fetch: headers on Request (URL from incoming request)', async ({ signal }); it('stub.fetch: Request + init — only init headers are sent', async ({ signal }) => { - const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-request-and-init', signal); + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario(signal); const body = await makeRequest('get', '/via-request-and-init'); await completedWithTraceCheck(); @@ -121,7 +94,7 @@ it('stub.fetch: Request + init — only init headers are sent', async ({ signal }); it('stub.fetch: does not append SDK baggage when the Request already includes Sentry baggage', async ({ signal }) => { - const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/with-preset-sentry-baggage', signal); + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario(signal); const body = await makeRequest('get', '/with-preset-sentry-baggage'); await completedWithTraceCheck(); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts index e157960aa080..006027d1b3a9 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts @@ -29,7 +29,6 @@ const mockFetch: typeof fetch = async () => export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts index f282f44aa7fd..5bebd6556ab3 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts @@ -11,9 +11,16 @@ import { GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, } from '@sentry/conventions/attributes'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; import { GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // This test runs the `@langchain/openai` model (backed by the // `openai` SDK, with a canned fetch) on the Workers runtime to verify the @@ -23,14 +30,14 @@ it('traces a LangChain chat model invocation', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /'); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan?.name).toBe('GET /'); - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); - expect(container.items).toHaveLength(1); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); - expect(container.items[0]).toEqual({ + expect(genAiSpans[0]).toEqual({ trace_id: expect.any(String), span_id: expect.any(String), parent_span_id: expect.any(String), @@ -54,6 +61,12 @@ it('traces a LangChain chat model invocation', async ({ signal }) => { [GEN_AI_RESPONSE_MODEL]: { value: 'gpt-3.5-turbo', type: 'string' }, [GEN_AI_RESPONSE_ID]: { value: 'chatcmpl-mock123', type: 'string' }, [GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE]: { value: 'stop', type: 'string' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts index 6b22dcd4388a..8bf8f8acd720 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts @@ -8,7 +8,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, dataCollection: { genAI: { inputs: true, outputs: true } }, }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts index 6615c79430b4..87008d27d870 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts @@ -1,5 +1,4 @@ import { expect, it } from 'vitest'; -import type { SerializedStreamedSpan } from '@sentry/core'; import { GEN_AI_AGENT_NAME, GEN_AI_INPUT_MESSAGES, @@ -11,6 +10,7 @@ import { GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // These tests are not exhaustive because the instrumentation is // already tested in the node integration tests and we merely @@ -21,22 +21,15 @@ it('traces langgraph compile and invoke operations', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - // Transaction item (first item in envelope) - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /'); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan?.name).toBe('GET /'); - // Span container item (second item in same envelope) - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); + expect(genAiSpans.map(span => span.name).sort()).toEqual(['invoke_agent weather_assistant']); - expect(container.items).toHaveLength(1); - expect(container.items.map((span: SerializedStreamedSpan) => span.name).sort()).toEqual([ - 'invoke_agent weather_assistant', - ]); - - const invokeAgentSpan = container.items.find( - (span: SerializedStreamedSpan) => span.name === 'invoke_agent weather_assistant', - ); + const invokeAgentSpan = genAiSpans.find(span => span.name === 'invoke_agent weather_assistant'); expect(invokeAgentSpan).toBeDefined(); expect(invokeAgentSpan!.status).toBe('ok'); expect(invokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts index 6b9cb7ee2bed..89c7664c830e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts @@ -29,7 +29,6 @@ const mockFetch: typeof fetch = async () => export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, dataCollection: { genAI: { inputs: true, outputs: false } }, }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts index 409941359399..3e2c98353373 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts @@ -12,8 +12,15 @@ import { GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, } from '@sentry/conventions/attributes'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // This test runs the `openai` SDK on the Workers runtime (with a canned // fetch) to verify the instrumentation works end-to-end on Cloudflare, not just @@ -23,17 +30,17 @@ it('traces a basic chat completion request with the openai SDK', async ({ signal const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /'); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan?.name).toBe('GET /'); - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); - expect(container.items).toHaveLength(1); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); - expect(container.items[0]).toEqual({ + expect(genAiSpans[0]).toEqual({ trace_id: expect.any(String), span_id: expect.any(String), - parent_span_id: expect.any(String), + parent_span_id: segmentSpan?.span_id, name: 'chat gpt-3.5-turbo', start_timestamp: expect.any(Number), end_timestamp: expect.any(Number), @@ -61,6 +68,12 @@ it('traces a basic chat completion request with the openai SDK', async ({ signal [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 15, type: 'integer' }, [GEN_AI_USAGE_TOTAL_TOKENS]: { value: 25, type: 'integer' }, [GEN_AI_RESPONSE_FINISH_REASONS]: { value: '["stop"]', type: 'string' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/index.ts index 3fe6e7fbb09a..d1a28a83b8a2 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/index.ts @@ -9,7 +9,6 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, - traceLifecycle: 'static', // Deliberately left unset — the global tracer provider stays OTel's noop, so the spans below // must not reach Sentry. }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/test.ts index 79d23f259e3c..9142a26fc3b3 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/test.ts @@ -1,33 +1,27 @@ import { SENTRY_ORIGIN } from '@sentry/conventions/attributes'; -import type { Event } from '@sentry/core'; import { expect, it } from 'vitest'; -import { SHORT_UUID_MATCHER } from '../../../../expect'; import { createRunner } from '../../../../runner'; +import { getSpansFromEnvelope } from '../../../../spanUtils'; it('drops spans emitted through @opentelemetry/api when `enableOpenTelemetrySetup` is not enabled', async ({ signal, }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const childSpans = spans.filter(span => !span.is_segment); - expect(transactionEvent.transaction).toBe('GET /'); + expect(segmentSpan?.name).toBe('GET /'); - // Only the Sentry span survives; it re-parents onto the request span because the noop OTel + // Only the Sentry span survives. It re-parents onto the segment span, because the noop OTel // span it was nested under never became a real parent. - expect(transactionEvent.spans).toEqual([ - { - data: { [SENTRY_ORIGIN]: 'manual' }, - description: 'sentry child', - parent_span_id: transactionEvent.contexts?.trace?.span_id, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: transactionEvent.contexts?.trace?.trace_id, - origin: 'manual', - }, - ]); + expect(childSpans).toHaveLength(1); + expect(childSpans[0]?.name).toBe('sentry child'); + expect(childSpans[0]?.parent_span_id).toBe(segmentSpan?.span_id); + expect(childSpans[0]?.trace_id).toBe(segmentSpan?.trace_id); + expect(childSpans[0]?.status).toBe('ok'); + expect(childSpans[0]?.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'manual' }); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/index.ts index 5cd8e1547a7e..98011aa2c857 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/index.ts @@ -9,7 +9,6 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, - traceLifecycle: 'static', enableOpenTelemetrySetup: true, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/test.ts index 564dd31f7684..9b9ccec84e1c 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/test.ts @@ -1,102 +1,63 @@ import { SENTRY_KIND, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; -import type { Event } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { expect, it } from 'vitest'; -import { SHORT_UUID_MATCHER } from '../../../../expect'; import { createRunner } from '../../../../runner'; +import { getSpansFromEnvelope } from '../../../../spanUtils'; it('captures spans emitted through @opentelemetry/api and nests them with Sentry spans', async ({ signal }) => { + let spans: SerializedStreamedSpan[] = []; + const runner = createRunner(__dirname) .expect(envelope => { - const event = envelope[1]?.[0]?.[1] as Event; - expect(event.transaction).toBe('GET /'); - - const requestSpanId = event.contexts?.trace?.span_id; - const traceId = event.contexts?.trace?.trace_id; - - // Cloudflare installs no OTel context manager, so `context.active()` never carries a span. - // Neither tracer API passes an explicit context here, so both fall back to the Sentry active - // span — the incoming request span — and everything stays in the request transaction. - const otelParentSpanId = event.spans?.[1]?.span_id; - const sentryChildSpanId = event.spans?.[2]?.span_id; + const envelopeSpans = getSpansFromEnvelope(envelope); - // Spans are ordered by start time. - expect(event.spans).toEqual([ - { - data: { [SENTRY_ORIGIN]: 'manual', 'test.attribute': 'inactive' }, - description: 'otel inactive', - parent_span_id: requestSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - { - data: { [SENTRY_ORIGIN]: 'manual', [SENTRY_KIND]: 'client', 'test.attribute': 'parent' }, - description: 'otel parent', - parent_span_id: requestSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - // Below the OTel parent the two APIs interleave correctly: the tracer publishes its active - // span on the Sentry scope, so the Sentry span picks it up as parent and the next OTel span - // picks up the Sentry one in turn. - { - data: { [SENTRY_ORIGIN]: 'manual' }, - description: 'sentry child', - parent_span_id: otelParentSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - { - data: { [SENTRY_ORIGIN]: 'manual', 'test.attribute': 'grandchild' }, - description: 'otel grandchild', - parent_span_id: sentryChildSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - // Without an OTel context manager `context.with` cannot restore anything, so the tracer has to - // fork the scope itself. Otherwise the finished `otel parent` would stay active and both of - // these would hang off it instead of the request span. - { - data: { [SENTRY_ORIGIN]: 'manual', 'test.attribute': 'after' }, - description: 'otel after active', - parent_span_id: requestSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - { - data: { [SENTRY_ORIGIN]: 'manual' }, - description: 'sentry after active', - parent_span_id: requestSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - ]); + expect(envelopeSpans).toHaveLength(7); + spans = envelopeSpans; }) .start(signal); await runner.makeRequest('get', '/'); await runner.completed(); + + // Start timestamps tie at millisecond resolution, so the spans are matched by name rather than + // by order. + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan?.name).toBe('GET /'); + + const otelInactive = spans.find(span => span.name === 'otel inactive'); + const otelParent = spans.find(span => span.name === 'otel parent'); + const sentryChild = spans.find(span => span.name === 'sentry child'); + const otelGrandchild = spans.find(span => span.name === 'otel grandchild'); + const otelAfterActive = spans.find(span => span.name === 'otel after active'); + const sentryAfterActive = spans.find(span => span.name === 'sentry after active'); + + for (const span of spans.filter(span => !span.is_segment)) { + expect(span.trace_id).toBe(segmentSpan?.trace_id); + expect(span.status).toBe('ok'); + expect(span.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'manual' }); + } + + // Cloudflare installs no OTel context manager, so `context.active()` never carries a span. + // Neither tracer API passes an explicit context here, so both fall back to the Sentry active + // span, the incoming request span, and everything stays under the segment. + expect(otelInactive?.parent_span_id).toBe(segmentSpan?.span_id); + expect(otelInactive?.attributes['test.attribute']).toEqual({ type: 'string', value: 'inactive' }); + + expect(otelParent?.parent_span_id).toBe(segmentSpan?.span_id); + expect(otelParent?.attributes['test.attribute']).toEqual({ type: 'string', value: 'parent' }); + expect(otelParent?.attributes[SENTRY_KIND]).toEqual({ type: 'string', value: 'client' }); + + // Below the OTel parent the two APIs interleave correctly: the tracer publishes its active span + // on the Sentry scope, so the Sentry span picks it up as parent and the next OTel span picks up + // the Sentry one in turn. + expect(sentryChild?.parent_span_id).toBe(otelParent?.span_id); + expect(otelGrandchild?.parent_span_id).toBe(sentryChild?.span_id); + expect(otelGrandchild?.attributes['test.attribute']).toEqual({ type: 'string', value: 'grandchild' }); + + // Without an OTel context manager `context.with` cannot restore anything, so the tracer has to + // fork the scope itself. Otherwise the finished `otel parent` would stay active and both of these + // would hang off it instead of the segment span. + expect(otelAfterActive?.parent_span_id).toBe(segmentSpan?.span_id); + expect(otelAfterActive?.attributes['test.attribute']).toEqual({ type: 'string', value: 'after' }); + expect(sentryAfterActive?.parent_span_id).toBe(segmentSpan?.span_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/index.ts index 3c5e20237fa0..84e9f42a2316 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/index.ts @@ -14,7 +14,6 @@ export default { { options: { dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1, enableOpenTelemetrySetup: true, }, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/test.ts index 7354baf802e2..4277c029c56b 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/test.ts @@ -1,60 +1,39 @@ import { SENTRY_ORIGIN } from '@sentry/conventions/attributes'; -import type { Event } from '@sentry/core'; import { expect, it } from 'vitest'; -import { SHORT_UUID_MATCHER } from '../../../../expect'; import { createRunner } from '../../../../runner'; +import { getSpanOp } from '../../../../spanUtils'; it('captures spans emitted through @opentelemetry/api inside _INTERNAL_wrapRequestHandler', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect(envelope => { - const event = envelope[1]?.[0]?.[1] as Event; - expect(event.transaction).toBe('GET /'); - expect(event.contexts?.trace?.op).toBe('http.server'); + const runner = createRunner(__dirname).start(signal); - const requestSpanId = event.contexts?.trace?.span_id; - const traceId = event.contexts?.trace?.trace_id; - const handleSpanId = event.spans?.[0]?.span_id; - const sentryChildSpanId = event.spans?.[1]?.span_id; - - // Spans are ordered by start time. - expect(event.spans).toEqual([ - { - data: { [SENTRY_ORIGIN]: 'manual' }, - description: 'sveltekit.handle.root', - parent_span_id: requestSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - { - data: { [SENTRY_ORIGIN]: 'manual' }, - description: 'sentry child', - parent_span_id: handleSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - { - data: { [SENTRY_ORIGIN]: 'manual', 'http.route': '/' }, - description: 'sveltekit.resolve', - parent_span_id: sentryChildSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - ]); - }) - .start(signal); + // The segment span arrives in its own envelope. It ends after the children it wraps, but each + // envelope is its own request to the mock server, so it can still be received first. Waiting for + // the three children by name rather than for the segment keeps the assertions below reliable. + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => + ['sveltekit.handle.root', 'sentry child', 'sveltekit.resolve'].every(name => + spansOfTrace.some(span => span.name === name), + ) && spansOfTrace.some(span => span.is_segment), + ); await runner.makeRequest('get', '/'); - await runner.completed(); + + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + + const handleSpan = spans.find(span => span.name === 'sveltekit.handle.root'); + const sentryChild = spans.find(span => span.name === 'sentry child'); + const resolveSpan = spans.find(span => span.name === 'sveltekit.resolve'); + + for (const span of spans.filter(span => !span.is_segment)) { + expect(span.trace_id).toBe(segmentSpan?.trace_id); + expect(span.status).toBe('ok'); + expect(span.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'manual' }); + } + + expect(handleSpan?.parent_span_id).toBe(segmentSpan?.span_id); + expect(sentryChild?.parent_span_id).toBe(handleSpan?.span_id); + expect(resolveSpan?.parent_span_id).toBe(sentryChild?.span_id); + expect(resolveSpan?.attributes['http.route']).toEqual({ type: 'string', value: '/' }); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/index.ts index 1cfcb50fb0b5..db0f305f8084 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/index.ts @@ -13,7 +13,6 @@ export default { async fetch(request, env, ctx) { const options: CloudflareOptions = { dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1, enableOpenTelemetrySetup: true, }; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts index 8e36aa1f8ea4..4b778ce5eab1 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts @@ -7,7 +7,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts index a09128d28fc0..a4953606c420 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts @@ -7,35 +7,30 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpansFromEnvelope } from '../../../spanUtils'; -it('Scheduled handler creates transaction with correct attributes', async ({ signal }) => { +it('Scheduled handler creates a segment span with correct attributes', async ({ signal }) => { const runner = createRunner(__dirname) .withWranglerArgs('--test-scheduled') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( + const spans = getSpansFromEnvelope(envelope); + + expect(spans).toHaveLength(1); + expect(spans[0]).toEqual( expect.objectContaining({ - type: 'transaction', - transaction: expect.stringMatching(/^Scheduled Cron/), - transaction_info: { source: 'task' }, - spans: [], - contexts: expect.objectContaining({ - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - op: 'function', - origin: 'auto.faas.cloudflare.scheduled', - status: 'ok', - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.scheduled', - [SENTRY_SEGMENT_NAME_SOURCE]: 'task', - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - 'faas.cron': expect.any(String), - 'faas.time': expect.any(String), - 'faas.trigger': 'timer', - }, - }, + name: expect.stringMatching(/^Scheduled Cron/), + span_id: expect.any(String), + trace_id: expect.any(String), + is_segment: true, + status: 'ok', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'function' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.faas.cloudflare.scheduled' }, + [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'task' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + 'faas.cron': { type: 'string', value: expect.any(String) }, + 'faas.time': { type: 'string', value: expect.any(String) }, + 'faas.trigger': { type: 'string', value: 'timer' }, }), }), ); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts index 619fe9a58cfb..e48428c9c732 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts @@ -12,7 +12,6 @@ const aiBinding = new MockAi(); export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, // Responses only. Asserting that prompts are *absent* is what makes this suite fail if the // binding is ever wrapped before the SDK is initialized again, since the fallback collects both. diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts index fdca58aee958..78d23778bd13 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts @@ -10,10 +10,17 @@ import { GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, } from '@sentry/conventions/attributes'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; import { expect, it } from 'vitest'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // These tests are not exhaustive because the instrumentation is // already tested in the core unit tests and we merely want to test @@ -23,14 +30,14 @@ it('traces a basic Workers AI text generation request', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /'); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan?.name).toBe('GET /'); - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); - expect(container.items).toHaveLength(1); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); - expect(container.items[0]).toEqual( + expect(genAiSpans[0]).toEqual( expect.objectContaining({ name: 'chat @cf/meta/llama-3.1-8b-instruct', status: 'ok', @@ -55,6 +62,12 @@ it('traces a basic Workers AI text generation request', async ({ signal }) => { type: 'string', value: 'The capital of France is Paris.', }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }), ); @@ -68,14 +81,16 @@ it('traces a streaming Workers AI text generation request', async ({ signal }) = const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /stream'); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + // `/stream` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/stream' }); - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); - expect(container.items).toHaveLength(1); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); - expect(container.items[0]).toEqual( + expect(genAiSpans[0]).toEqual( expect.objectContaining({ name: 'chat @cf/meta/llama-3.1-8b-instruct', status: 'ok', @@ -100,6 +115,12 @@ it('traces a streaming Workers AI text generation request', async ({ signal }) = type: 'string', value: 'The capital of France is Paris.', }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }), ); @@ -115,8 +136,8 @@ it('traces a streaming Workers AI text generation request', async ({ signal }) = // Sentry exactly once, with the `auto.http.cloudflare` mechanism. it('bubbles up Workers AI errors to be captured by the top-level handler', async ({ signal }) => { const runner = createRunner(__dirname) - // A failing run still produces a (sampled) transaction; we only care about the error event here. - .ignore('transaction') + // A failing run still streams its (sampled) spans; we only care about the error event here. + .ignore('span') .expect(envelope => { const errorEvent = envelope[1]?.[0]?.[1] as any; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts index 9abcc3967842..dce6c1d58ced 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts @@ -22,7 +22,6 @@ class MyWorkflowBase extends WorkflowEntrypoint { export const MyWorkflow = Sentry.instrumentWorkflowWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyWorkflowBase, @@ -31,7 +30,6 @@ export const MyWorkflow = Sentry.instrumentWorkflowWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts index 1224da14d358..908e6d3f5ec5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts @@ -5,71 +5,47 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpansFromEnvelope } from '../../../spanUtils'; -it('Workflow steps create transactions with correct attributes', async ({ signal }) => { +it('Workflow steps create segment spans with correct attributes', async ({ signal }) => { + let spans: SerializedStreamedSpan[] = []; + + // Both steps run in one trace and arrive in one envelope. The trigger request runs in its own + // trace, so its envelope carries no step span and does not match here. const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'step-one', - transaction_info: { source: 'task' }, - spans: [], - contexts: expect.objectContaining({ - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - op: 'function', - origin: 'auto.faas.cloudflare.workflow', - status: 'ok', - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.workflow', - [SENTRY_SEGMENT_NAME_SOURCE]: 'task', - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - 'code.function.name': 'step-one', - 'workflow.step.name': 'step-one', - 'cloudflare.workflow.attempt': 1, - }, - }, - }), - }), - ); - }) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'step-two', - transaction_info: { source: 'task' }, - spans: [], - contexts: expect.objectContaining({ - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - op: 'function', - origin: 'auto.faas.cloudflare.workflow', - status: 'ok', - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.workflow', - [SENTRY_SEGMENT_NAME_SOURCE]: 'task', - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - 'code.function.name': 'step-two', - 'workflow.step.name': 'step-two', - 'cloudflare.workflow.attempt': 1, - }, - }, - }), - }), - ); + const envelopeSpans = getSpansFromEnvelope(envelope); + + expect(envelopeSpans.some(span => span.name === 'step-one')).toBe(true); + expect(envelopeSpans.some(span => span.name === 'step-two')).toBe(true); + spans = envelopeSpans; }) .unordered() .start(signal); await runner.makeRequest('get', '/workflow/trigger'); await runner.completed(); + + for (const stepName of ['step-one', 'step-two']) { + expect(spans.find(span => span.name === stepName)).toEqual( + expect.objectContaining({ + name: stepName, + span_id: expect.any(String), + trace_id: expect.any(String), + is_segment: true, + status: 'ok', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'function' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.faas.cloudflare.workflow' }, + [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'task' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + 'code.function.name': { type: 'string', value: stepName }, + 'workflow.step.name': { type: 'string', value: stepName }, + 'cloudflare.workflow.attempt': { type: 'integer', value: 1 }, + }), + }), + ); + } });