From d88f7b68bafd700962ef6b583b1af45c5c7553c9 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 7 Sep 2026 16:55:40 +0200 Subject: [PATCH] test(cloudflare): Port the tracing suites to span streaming Removes the `traceLifecycle: 'static'` pin from the `suites/tracing` suites, and rewrites the assertions from transaction envelopes to span v2. The `tracing/propagation` suites follow in a separate PR. `tracing/d1` also loses its env-gated `STREAMED` switch and the static copy of its test, so one test now covers the streamed behaviour. Most suites keep the shape they had, and only the assertions change: a span carries its name in `name` rather than `description`, its op and origin as attributes rather than fields, and every attribute as a `{ type, value }` pair. The gen_ai suites keep asserting the complete attribute set, so an unexpected attribute still fails the test. Streaming adds six attributes to every span: `sentry.trace_lifecycle`, `sentry.segment.name`, `sentry.segment.id`, `sentry.sdk.name`, `sentry.sdk.version` and `sentry.environment`. The runs surfaced three behaviours that shape the rewrite. A `url` source segment span keeps the method only. `GET /error` becomes `GET`, so those suites assert `GET` and read the route from the `url.path` attribute. The segment span ends last, so it can arrive in a later envelope than its children. `opentelemetry-tracer/internal-request-handler` failed on that. It waits for its three child spans by name: each envelope is its own request to the mock server, so waiting for the segment alone would let it resolve before the envelope carrying those children arrives. Start timestamps tie at millisecond resolution in workerd, so an order-based assertion over child spans is unreliable. `opentelemetry-tracer/enabled` matches spans by name instead. `instrument-fetcher` uses `collectStreamedSpans`, because the worker and the Durable Object stream their segment spans from separate isolates. The Durable Object storage suites keep a single envelope expectation, because their spans do arrive together. `workers-ai` ignores the `span` envelope where it used to ignore `transaction`. Its error assertion never read the trace, and `transaction` is now an envelope type nothing emits. Co-Authored-By: Claude Opus 5 (1M context) --- .../suites/tracing/anthropic-ai/index.ts | 1 - .../suites/tracing/anthropic-ai/test.ts | 25 +- .../suites/tracing/d1/index.ts | 3 +- .../suites/tracing/d1/test.ts | 295 ++++++------------ .../suites/tracing/dsc-url-source/index.ts | 1 - .../suites/tracing/dsc-url-source/test.ts | 25 +- .../durableobject-alarm-links-sync/index.ts | 2 - .../durableobject-alarm-links-sync/test.ts | 68 ++-- .../durableobject-alarm-links/index.ts | 2 - .../tracing/durableobject-alarm-links/test.ts | 68 ++-- .../durableobject-rpc-private-fields/index.ts | 2 - .../durableobject-rpc-private-fields/test.ts | 80 ++--- .../tracing/durableobject-spans/index.ts | 2 - .../tracing/durableobject-spans/test.ts | 63 ++-- .../suites/tracing/durableobject-sql/index.ts | 2 - .../suites/tracing/durableobject-sql/test.ts | 75 +++-- .../tracing/durableobject-sync-kv/index.ts | 2 - .../tracing/durableobject-sync-kv/test.ts | 64 ++-- .../suites/tracing/durableobject/index.ts | 2 - .../suites/tracing/durableobject/test.ts | 60 +--- .../suites/tracing/google-genai/index.ts | 1 - .../suites/tracing/google-genai/test.ts | 37 ++- .../suites/tracing/headers/index.ts | 1 - .../tracing/instrument-fetcher/index.ts | 2 - .../suites/tracing/instrument-fetcher/test.ts | 85 ++--- .../suites/tracing/langchain/index.ts | 1 - .../suites/tracing/langchain/test.ts | 25 +- .../suites/tracing/langgraph/index.ts | 1 - .../suites/tracing/langgraph/test.ts | 23 +- .../suites/tracing/openai/index.ts | 1 - .../suites/tracing/openai/test.ts | 27 +- .../opentelemetry-tracer/disabled/index.ts | 1 - .../opentelemetry-tracer/disabled/test.ts | 30 +- .../opentelemetry-tracer/enabled/index.ts | 1 - .../opentelemetry-tracer/enabled/test.ts | 137 +++----- .../internal-request-handler/index.ts | 1 - .../internal-request-handler/test.ts | 81 ++--- .../subpath-request-handler/index.ts | 1 - .../suites/tracing/scheduled/index.ts | 1 - .../suites/tracing/scheduled/test.ts | 43 ++- .../suites/tracing/workers-ai/index.ts | 1 - .../suites/tracing/workers-ai/test.ts | 49 ++- .../suites/tracing/workflow/index.ts | 2 - .../suites/tracing/workflow/test.ts | 90 ++---- 44 files changed, 600 insertions(+), 884 deletions(-) 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 }, + }), + }), + ); + } });