diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index 19873330d82e..76e83f15abc7 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -1,11 +1,12 @@ -import type { Envelope, EnvelopeItemType } from '@sentry/core'; +import type { Envelope, EnvelopeItemType, SerializedStreamedSpan } from '@sentry/core'; import { normalize } from '@sentry/core'; import { createBasicSentryServer } from '@sentry-internal/test-utils'; import { spawn, spawnSync } from 'child_process'; import { existsSync, readdirSync, readFileSync } from 'fs'; import { join } from 'path'; import { inspect } from 'util'; -import { expect } from 'vitest'; +import { expect, onTestFinished } from 'vitest'; +import { getSpansFromEnvelope } from './spanUtils'; const CLEANUP_STEPS = new Set<() => void>(); @@ -139,6 +140,9 @@ function deferredPromise( type Expected = Envelope | ((envelope: Envelope) => void); +/** Either the name of the segment span, or a predicate over it. */ +type SegmentMatcher = string | ((segmentSpan: SerializedStreamedSpan) => boolean); + type StartResult = { completed(): Promise; makeRequest( @@ -152,6 +156,25 @@ type StartResult = { expected: Expected | Expected[], options?: { headers?: Record; data?: BodyInit; expectError?: boolean }, ): Promise; + /** + * Accumulates spans across envelopes, grouped by trace, and resolves with the spans of the first + * trace that satisfies `isDone`. + * + * A trace reaches the mock server in more than one envelope: the span buffer flushes on a timer, so + * a segment that is still open when its children flush arrives separately, and a Durable Object or a + * service binding sends its own spans from its own isolate. Anything asserting on a whole trace has + * to accumulate rather than read a single envelope. + */ + collectStreamedSpans(isDone: (spansOfTrace: SerializedStreamedSpan[]) => boolean): Promise; + /** + * Accumulates the spans of a trace until its segment span has arrived. + * + * Only use this to assert on the segment span itself. The segment span ends last, but each + * envelope is its own request to the mock server, so the segment can still be *received* before + * the envelope carrying its children. A suite that asserts on the children has to wait for those + * children by name or by count through `collectStreamedSpans`. + */ + collectStreamedSpansUntilSegment(segment: SegmentMatcher): Promise; }; /** Creates a test runner */ @@ -211,17 +234,55 @@ export function createRunner(...paths: string[]) { return this; }, start: function (signal?: AbortSignal): StartResult { - const { resolve, reject, promise: isComplete } = deferredPromise(cleanupChildProcesses); + let child: ReturnType | undefined; + let childSubWorker: ReturnType | undefined; + + // Tears down this runner only. `cleanupChildProcesses` tears down every registered runner, so + // running it here would kill a worker another test has already started: a runner whose + // `isComplete` settles after its own test (a suite that asserts on streamed spans never calls + // `completed()`, so the abort signal settles it) would take the next test's worker with it. + // The mock server has to close here as well, otherwise one server per scenario stays listening + // for the whole run. + function cleanupThisRunner(): void { + child?.kill(); + childSubWorker?.kill(); + closeMockServer?.(); + closeMockServer = undefined; + } + + // A suite that asserts on streamed spans never calls `completed()`, so `isComplete` never + // settles and its worker would stay alive until the vitest process exits. With one such suite + // per file, a full run ends up with dozens of `wrangler dev` processes competing for the + // machine, and the later suites time out. Tie the teardown to the test instead. + onTestFinished(cleanupThisRunner); + + let closeMockServer: (() => void) | undefined; + + const { resolve, reject, promise: isComplete } = deferredPromise(cleanupThisRunner); + + const spanWaiters: { + onSpans: (spans: SerializedStreamedSpan[]) => boolean; + resolve: () => void; + reject: (e: unknown) => void; + }[] = []; + let failure: unknown; // `reject` is called from background event handlers (child process `error`/`exit`, mock server // callbacks) that fire at arbitrary times relative to the test's `await` points. If `reject` runs // while nothing is awaiting `isComplete` yet (e.g. a child transiently exits while the test is // parked in `makeRequest`), the rejection has no handler attached and surfaces as an unhandled // promise rejection — which Vitest reports as a spurious "Unhandled error" that fails the whole - // suite. Attaching a no-op catch keeps the promise "handled"; the real rejection is still delivered + // suite. Attaching a catch keeps the promise "handled"; the real rejection is still delivered // to callers via `completed()`, so genuine failures still fail the test. - isComplete.catch(() => { - // handled in `completed()` + // + // A test that only asserts on streamed spans never calls `completed()`, so the same rejection is + // handed to the span waiters as well. Without it, a worker that fails to boot would surface as a + // Vitest timeout instead of the actual error. + isComplete.catch(e => { + failure = e; + for (const waiter of spanWaiters.splice(0)) { + waiter.reject(e); + } }); const expectedEnvelopeCount = expectedEnvelopes.length; @@ -237,8 +298,6 @@ export function createRunner(...paths: string[]) { workerPortPromise.catch(() => { // handled in `makeRequest` }); - let child: ReturnType | undefined; - let childSubWorker: ReturnType | undefined; /** Called after each expect callback to check if we're complete */ function expectCallbackCalled(): void { @@ -254,6 +313,44 @@ export function createRunner(...paths: string[]) { }); } + /** Resolves once `onSpans` returns true for the spans of an arriving span envelope. */ + function waitForSpans(onSpans: (spans: SerializedStreamedSpan[]) => boolean): Promise { + return new Promise((resolveWaiter, rejectWaiter) => { + if (failure) { + rejectWaiter(failure); + return; + } + spanWaiters.push({ onSpans, resolve: resolveWaiter, reject: rejectWaiter }); + }); + } + + /** + * Span waiters observe the envelope stream, they never consume from it: a suite can assert on + * streamed spans and on error envelopes at the same time. + */ + function notifySpanWaiters(envelope: Envelope): void { + const spans = getSpansFromEnvelope(envelope); + if (!spans.length) { + return; + } + + for (const waiter of spanWaiters.slice()) { + let done: boolean; + try { + done = waiter.onSpans(spans); + } catch (e) { + spanWaiters.splice(spanWaiters.indexOf(waiter), 1); + waiter.reject(e); + continue; + } + + if (done) { + spanWaiters.splice(spanWaiters.indexOf(waiter), 1); + waiter.resolve(); + } + } + } + function assertEnvelopeMatches(expected: Expected, envelope: Envelope): void { if (typeof expected === 'function') { expected(envelope); @@ -265,6 +362,8 @@ export function createRunner(...paths: string[]) { function newEnvelope(envelope: Envelope): void { if (process.env.DEBUG) log('newEnvelope', inspect(envelope, false, null, true)); + notifySpanWaiters(envelope); + const envelopeItemType = envelope[1][0][0].type; if (ignored.has(envelopeItemType)) { @@ -332,6 +431,7 @@ export function createRunner(...paths: string[]) { createBasicSentryServer(newEnvelope) .then(async ([mockServerPort, mockServerClose]) => { if (mockServerClose) { + closeMockServer = mockServerClose; CLEANUP_STEPS.add(() => { mockServerClose(); }); @@ -501,6 +601,44 @@ export function createRunner(...paths: string[]) { await Promise.all(envelopePromises); return result; }, + collectStreamedSpans: async function ( + isDone: (spansOfTrace: SerializedStreamedSpan[]) => boolean, + ): Promise { + const spansByTrace = new Map(); + let matched: SerializedStreamedSpan[] = []; + + await waitForSpans(spans => { + for (const span of spans) { + const spansOfTrace = spansByTrace.get(span.trace_id); + if (spansOfTrace) { + spansOfTrace.push(span); + } else { + spansByTrace.set(span.trace_id, [span]); + } + } + + // Every trace is a candidate, so a trace that never satisfies `isDone` cannot hold up the + // one that does. Insertion order means the earliest-arriving trace wins a tie. + for (const spansOfTrace of spansByTrace.values()) { + if (isDone(spansOfTrace)) { + matched = spansOfTrace; + return true; + } + } + + return false; + }); + + return matched; + }, + collectStreamedSpansUntilSegment: function (segment: SegmentMatcher): Promise { + const matchesSegment = + typeof segment === 'string' ? (span: SerializedStreamedSpan) => span.name === segment : segment; + + return this.collectStreamedSpans(spansOfTrace => + spansOfTrace.some(span => span.is_segment && matchesSegment(span)), + ); + }, }; }, }; diff --git a/dev-packages/cloudflare-integration-tests/spanUtils.ts b/dev-packages/cloudflare-integration-tests/spanUtils.ts new file mode 100644 index 000000000000..f0a79b2c6e4b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/spanUtils.ts @@ -0,0 +1,18 @@ +import type { Envelope, SerializedStreamedSpan, SerializedStreamedSpanContainer } from '@sentry/core'; + +export { getSpanOp } from '@sentry-internal/test-utils'; + +/** + * The span v2 container of an envelope, or `undefined` when the envelope carries no span item. + */ +export function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer | undefined { + const spanItem = envelope[1].find(item => item[0].type === 'span'); + return spanItem?.[1] as SerializedStreamedSpanContainer | undefined; +} + +/** + * The spans of an envelope, or an empty array when the envelope carries no span item. + */ +export function getSpansFromEnvelope(envelope: Envelope): SerializedStreamedSpan[] { + return getSpanContainer(envelope)?.items ?? []; +} diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts deleted file mode 100644 index 8c6b18c3931d..000000000000 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; -import { - SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, - SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, -} from '@sentry/core'; -import { - SENTRY_SEGMENT_NAME_SOURCE, - SENTRY_SDK_NAME, - SENTRY_SDK_VERSION, - SENTRY_SEGMENT_ID, - SENTRY_SEGMENT_NAME, - SENTRY_TRACE_LIFECYCLE, -} from '@sentry/conventions/attributes'; -import { expect, it } from 'vitest'; -import { createRunner } from '../../../runner'; - -const CLOUDFLARE_SDK = 'sentry.javascript.cloudflare'; - -function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { - const spanItem = envelope[1].find(item => item[0].type === 'span'); - expect(spanItem).toBeDefined(); - return spanItem![1] as SerializedStreamedSpanContainer; -} - -it('sends a streamed span envelope with correct envelope header', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect(envelope => { - expect(getSpanContainer(envelope).items.length).toBeGreaterThan(0); - - expect(envelope[0]).toEqual( - expect.objectContaining({ - sent_at: expect.any(String), - sdk: { - name: CLOUDFLARE_SDK, - version: SDK_VERSION, - }, - trace: expect.objectContaining({ - public_key: 'public', - sample_rate: '1', - sampled: 'true', - trace_id: expect.stringMatching(/^[\da-f]{32}$/), - }), - }), - ); - }) - .start(signal); - - await runner.makeRequest('get', '/'); - await runner.completed(); -}); - -it('sends a streamed span envelope with correct spans for a manually started span with children', async ({ - signal, -}) => { - const runner = createRunner(__dirname) - .expect(envelope => { - const container = getSpanContainer(envelope); - const spans = container.items; - - // Cloudflare `withSentry` wraps fetch in an http.server span (segment) around the scenario. - expect(spans.length).toBe(5); - - const segmentSpan = spans.find(s => !!s.is_segment); - expect(segmentSpan).toBeDefined(); - - const segmentSpanId = segmentSpan!.span_id; - const traceId = segmentSpan!.trace_id; - const segmentName = segmentSpan!.name; - - const parentTestSpan = spans.find(s => s.name === 'test-span'); - expect(parentTestSpan).toBeDefined(); - expect(parentTestSpan!.parent_span_id).toBe(segmentSpanId); - - const childSpan = spans.find(s => s.name === 'test-child-span'); - expect(childSpan).toBeDefined(); - expect(childSpan).toEqual({ - attributes: { - [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { - type: 'string', - value: 'test-child', - }, - [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, - [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, - }, - name: 'test-child-span', - is_segment: false, - parent_span_id: parentTestSpan!.span_id, - trace_id: traceId, - span_id: expect.stringMatching(/^[\da-f]{16}$/), - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - }); - - const inactiveSpan = spans.find(s => s.name === 'test-inactive-span'); - expect(inactiveSpan).toBeDefined(); - expect(inactiveSpan).toEqual({ - attributes: { - [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, - [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, - [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, - }, - links: [ - { - attributes: { - 'sentry.link.type': { - type: 'string', - value: 'some_relation', - }, - }, - sampled: true, - span_id: parentTestSpan!.span_id, - trace_id: traceId, - }, - ], - name: 'test-inactive-span', - is_segment: false, - parent_span_id: parentTestSpan!.span_id, - trace_id: traceId, - span_id: expect.stringMatching(/^[\da-f]{16}$/), - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - }); - - const manualSpan = spans.find(s => s.name === 'test-manual-span'); - expect(manualSpan).toBeDefined(); - expect(manualSpan).toEqual({ - attributes: { - [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, - [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, - [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, - }, - name: 'test-manual-span', - is_segment: false, - parent_span_id: parentTestSpan!.span_id, - trace_id: traceId, - span_id: expect.stringMatching(/^[\da-f]{16}$/), - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - }); - - expect(parentTestSpan).toEqual({ - attributes: { - [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, - [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, - [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, - }, - name: 'test-span', - is_segment: false, - parent_span_id: segmentSpanId, - trace_id: traceId, - span_id: parentTestSpan!.span_id, - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - }); - - expect(segmentSpan).toEqual({ - attributes: { - [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, - [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, - [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { - type: 'array', - value: expect.arrayContaining(['SpanStreaming']), - }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.cloudflare' }, - [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'http.server' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, - [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'route' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, - 'server.address': { - type: 'string', - value: 'localhost', - }, - 'url.full': { - type: 'string', - value: expect.stringMatching(/^http:\/\/localhost:.+$/), - }, - 'url.path': { - type: 'string', - value: '/', - }, - 'url.port': { - type: 'string', - value: expect.stringMatching(/^\d{4,5}$/), - }, - 'url.scheme': { - type: 'string', - value: 'http:', - }, - 'user_agent.original': { - type: 'string', - value: 'node', - }, - 'http.request.header.accept': { - type: 'string', - value: '*/*', - }, - 'http.request.header.accept_encoding': { - type: 'string', - value: 'br, gzip', - }, - 'http.request.header.accept_language': { - type: 'string', - value: '*', - }, - 'http.request.header.cf_connecting_ip': { - type: 'string', - value: '127.0.0.1', - }, - 'user.ip_address': { - type: 'string', - value: '127.0.0.1', - }, - 'http.request.header.host': { - type: 'string', - value: expect.stringMatching(/^localhost:.+$/), - }, - 'http.request.header.sec_fetch_mode': { - type: 'string', - value: 'cors', - }, - 'http.request.header.user_agent': { - type: 'string', - value: 'node', - }, - 'http.request.method': { - type: 'string', - value: 'GET', - }, - 'http.response.status_code': { - type: 'integer', - value: 200, - }, - 'cloud.provider': { - type: 'string', - value: 'cloudflare', - }, - 'culture.timezone': { - type: 'string', - value: expect.any(String), - }, - 'network.protocol.name': { - type: 'string', - value: 'http', - }, - 'network.protocol.version': { - type: 'string', - value: '1.1', - }, - }, - is_segment: true, - trace_id: traceId, - span_id: segmentSpanId, - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - name: 'GET /', - }); - }) - .start(signal); - - await runner.makeRequest('get', '/'); - await runner.completed(); -}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/index.ts similarity index 96% rename from dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts rename to dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/index.ts index 76039b6892ee..328cc01c19de 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/index.ts @@ -8,7 +8,6 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, - traceLifecycle: 'stream', release: '1.0.0', }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts new file mode 100644 index 000000000000..2685a1574340 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts @@ -0,0 +1,291 @@ +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, +} from '@sentry/core'; +import { + SENTRY_SEGMENT_NAME_SOURCE, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, +} from '@sentry/conventions/attributes'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpansFromEnvelope } from '../../../spanUtils'; + +const CLOUDFLARE_SDK = 'sentry.javascript.cloudflare'; + +it('sends a streamed span envelope with correct envelope header', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + expect(getSpansFromEnvelope(envelope).length).toBeGreaterThan(0); + + expect(envelope[0]).toEqual( + expect.objectContaining({ + sent_at: expect.any(String), + sdk: { + name: CLOUDFLARE_SDK, + version: SDK_VERSION, + }, + trace: expect.objectContaining({ + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }), + }), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); +}); + +it('sends a streamed span envelope with correct spans for a manually started span with children', async ({ + signal, +}) => { + const runner = createRunner(__dirname).start(signal); + // Cloudflare `withSentry` wraps fetch in an http.server span (segment) around the scenario, so + // the trace holds five spans. Waiting for all of them rather than for the segment alone: the + // segment ends last but each envelope is its own request, so it can arrive before its children. + const spansPromise = runner.collectStreamedSpans(spansOfTrace => spansOfTrace.length === 5); + + await runner.makeRequest('get', '/'); + + const spans = await spansPromise; + + expect(spans.length).toBe(5); + + const segmentSpan = spans.find(s => !!s.is_segment); + expect(segmentSpan).toBeDefined(); + + const segmentSpanId = segmentSpan!.span_id; + const traceId = segmentSpan!.trace_id; + const segmentName = segmentSpan!.name; + + const parentTestSpan = spans.find(s => s.name === 'test-span'); + expect(parentTestSpan).toBeDefined(); + expect(parentTestSpan!.parent_span_id).toBe(segmentSpanId); + + const childSpan = spans.find(s => s.name === 'test-child-span'); + expect(childSpan).toBeDefined(); + expect(childSpan).toEqual({ + attributes: { + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'test-child', + }, + [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + }, + name: 'test-child-span', + is_segment: false, + parent_span_id: parentTestSpan!.span_id, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + const inactiveSpan = spans.find(s => s.name === 'test-inactive-span'); + expect(inactiveSpan).toBeDefined(); + expect(inactiveSpan).toEqual({ + attributes: { + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + }, + links: [ + { + attributes: { + 'sentry.link.type': { + type: 'string', + value: 'some_relation', + }, + }, + sampled: true, + span_id: parentTestSpan!.span_id, + trace_id: traceId, + }, + ], + name: 'test-inactive-span', + is_segment: false, + parent_span_id: parentTestSpan!.span_id, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + const manualSpan = spans.find(s => s.name === 'test-manual-span'); + expect(manualSpan).toBeDefined(); + expect(manualSpan).toEqual({ + attributes: { + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + }, + name: 'test-manual-span', + is_segment: false, + parent_span_id: parentTestSpan!.span_id, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + expect(parentTestSpan).toEqual({ + attributes: { + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, + [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + }, + name: 'test-span', + is_segment: false, + parent_span_id: segmentSpanId, + trace_id: traceId, + span_id: parentTestSpan!.span_id, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + expect(segmentSpan).toEqual({ + attributes: { + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + type: 'array', + value: expect.arrayContaining(['SpanStreaming']), + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.cloudflare' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'http.server' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'route' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + 'server.address': { + type: 'string', + value: 'localhost', + }, + 'url.full': { + type: 'string', + value: expect.stringMatching(/^http:\/\/localhost:.+$/), + }, + 'url.path': { + type: 'string', + value: '/', + }, + 'url.port': { + type: 'string', + value: expect.stringMatching(/^\d{4,5}$/), + }, + 'url.scheme': { + type: 'string', + value: 'http:', + }, + 'user_agent.original': { + type: 'string', + value: 'node', + }, + 'http.request.header.accept': { + type: 'string', + value: '*/*', + }, + 'http.request.header.accept_encoding': { + type: 'string', + value: 'br, gzip', + }, + 'http.request.header.accept_language': { + type: 'string', + value: '*', + }, + 'http.request.header.cf_connecting_ip': { + type: 'string', + value: '127.0.0.1', + }, + 'user.ip_address': { + type: 'string', + value: '127.0.0.1', + }, + 'http.request.header.host': { + type: 'string', + value: expect.stringMatching(/^localhost:.+$/), + }, + 'http.request.header.sec_fetch_mode': { + type: 'string', + value: 'cors', + }, + 'http.request.header.user_agent': { + type: 'string', + value: 'node', + }, + 'http.request.method': { + type: 'string', + value: 'GET', + }, + 'http.response.status_code': { + type: 'integer', + value: 200, + }, + 'cloud.provider': { + type: 'string', + value: 'cloudflare', + }, + 'culture.timezone': { + type: 'string', + value: expect.any(String), + }, + 'network.protocol.name': { + type: 'string', + value: 'http', + }, + 'network.protocol.version': { + type: 'string', + value: '1.1', + }, + }, + is_segment: true, + trace_id: traceId, + span_id: segmentSpanId, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + name: 'GET /', + }); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/wrangler.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/wrangler.jsonc rename to dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/index.ts similarity index 94% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/index.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/index.ts index 89b990b60865..69d880d9b5da 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/index.ts @@ -9,7 +9,6 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0, - traceLifecycle: 'stream', ignoreSpans: ['ignored-child'], tracePropagationTargets: [env.SERVER_URL], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/test.ts similarity index 66% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/test.ts index d9f587a14ccb..76394ced63a2 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/test.ts @@ -1,8 +1,7 @@ -import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; -import { SENTRY_OP } from '@sentry/conventions/attributes'; import { createTestServer } from '@sentry-internal/test-utils'; import { expect, it } from 'vitest'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('preserves a positive sampling decision across an ignored child span', async ({ signal }) => { const [serverUrl, closeTestServer] = await createTestServer() @@ -17,14 +16,14 @@ it('preserves a positive sampling decision across an ignored child span', async const runner = createRunner(__dirname) .withServerUrl(serverUrl) .expect(envelope => { - const container = getSpanContainer(envelope); - const serverSpan = container.items.find(item => item.attributes[SENTRY_OP]?.value === 'http.server'); - const fetchSpan = container.items.find(item => item.attributes[SENTRY_OP]?.value === 'http.client'); + const spans = getSpansFromEnvelope(envelope); + const serverSpan = spans.find(span => getSpanOp(span) === 'http.server'); + const fetchSpan = spans.find(span => getSpanOp(span) === 'http.client'); expect(serverSpan?.is_segment).toBe(true); expect(serverSpan?.trace_id).toBe('12345678901234567890123456789012'); expect(fetchSpan?.parent_span_id).toBe(serverSpan?.span_id); - expect(container.items.some(item => item.name === 'ignored-child')).toBe(false); + expect(spans.some(span => span.name === 'ignored-child')).toBe(false); }) .start(signal); @@ -43,9 +42,3 @@ it('preserves a positive sampling decision across an ignored child span', async closeTestServer(); } }); - -function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { - const spanItem = envelope[1].find(item => item[0].type === 'span'); - expect(spanItem).toBeDefined(); - return spanItem![1] as SerializedStreamedSpanContainer; -} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/wrangler.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/wrangler.jsonc rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/index.ts similarity index 94% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/index.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/index.ts index eee940bc8f3a..21e062199a80 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/index.ts @@ -9,7 +9,6 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0, - traceLifecycle: 'stream', ignoreSpans: [{ attributes: { 'sentry.op': 'http.client' } }], tracePropagationTargets: [env.SERVER_URL], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/test.ts similarity index 70% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/test.ts index d7dc66316b88..0c390f63d877 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/test.ts @@ -1,8 +1,7 @@ -import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; -import { SENTRY_OP } from '@sentry/conventions/attributes'; import { createTestServer } from '@sentry-internal/test-utils'; import { expect, it } from 'vitest'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('preserves a positive sampling decision when the outgoing fetch span is ignored', async ({ signal }) => { let outgoingSentryTrace: string | string[] | undefined; @@ -19,13 +18,13 @@ it('preserves a positive sampling decision when the outgoing fetch span is ignor const runner = createRunner(__dirname) .withServerUrl(serverUrl) .expect(envelope => { - const container = getSpanContainer(envelope); - const serverSpan = container.items.find(item => item.attributes[SENTRY_OP]?.value === 'http.server'); + const spans = getSpansFromEnvelope(envelope); + const serverSpan = spans.find(span => getSpanOp(span) === 'http.server'); expect(serverSpan?.is_segment).toBe(true); expect(serverSpan?.trace_id).toBe('12345678901234567890123456789012'); expect(outgoingSentryTrace).toBe(`12345678901234567890123456789012-${serverSpan?.span_id}-1`); - expect(container.items.some(item => item.attributes[SENTRY_OP]?.value === 'http.client')).toBe(false); + expect(spans.some(span => getSpanOp(span) === 'http.client')).toBe(false); }) .start(signal); @@ -44,9 +43,3 @@ it('preserves a positive sampling decision when the outgoing fetch span is ignor closeTestServer(); } }); - -function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { - const spanItem = envelope[1].find(item => item[0].type === 'span'); - expect(spanItem).toBeDefined(); - return spanItem![1] as SerializedStreamedSpanContainer; -} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/wrangler.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/wrangler.jsonc rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-segment/index.ts similarity index 93% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/index.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-segment/index.ts index 457c8dce5ed6..08b098578610 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-segment/index.ts @@ -9,7 +9,6 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0, - traceLifecycle: 'stream', ignoreSpans: [{ op: 'http.server' }], tracePropagationTargets: [env.SERVER_URL], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-segment/test.ts similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-segment/test.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-segment/wrangler.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/wrangler.jsonc rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-segment/wrangler.jsonc