From 07971b315923dd8a8c541e643de93fdb5a081f14 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 8 Apr 2026 17:40:24 +0200 Subject: [PATCH 1/8] feat(cloudflare,deno,vercel-edge): Add span streaming support --- .../public-api/startSpan-streamed/index.ts | 36 +++ .../public-api/startSpan-streamed/test.ts | 264 ++++++++++++++++++ .../startSpan-streamed/wrangler.jsonc | 6 + packages/cloudflare/src/sdk.ts | 8 +- packages/cloudflare/test/sdk.test.ts | 26 ++ packages/deno/src/sdk.ts | 8 +- packages/vercel-edge/src/sdk.ts | 8 +- 7 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/wrangler.jsonc 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-streamed/index.ts new file mode 100644 index 000000000000..76039b6892ee --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + release: '1.0.0', + }), + { + async fetch(_request, _env, _ctx) { + Sentry.startSpan({ name: 'test-span', op: 'test' }, segmentSpan => { + Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => { + // noop + }); + + const inactiveSpan = Sentry.startInactiveSpan({ name: 'test-inactive-span' }); + inactiveSpan.addLink({ + context: segmentSpan.spanContext(), + attributes: { 'sentry.link.type': 'some_relation' }, + }); + inactiveSpan.end(); + + Sentry.startSpanManual({ name: 'test-manual-span' }, span => { + span.end(); + }); + }); + + return new Response('OK'); + }, + }, +); 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 new file mode 100644 index 000000000000..090142714d5b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts @@ -0,0 +1,264 @@ +import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +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: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'test-child', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_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' }, + }, + 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: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + }, + 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: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + }, + 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: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_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' }, + }, + 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: { + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.cloudflare' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_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 }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'route' }, + 'sentry.span.source': { type: 'string', value: 'route' }, + '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: '8787', + }, + '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: '::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, + }, + 'network.protocol.name': { + type: 'string', + value: 'HTTP/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/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/wrangler.jsonc new file mode 100644 index 000000000000..b247aa82fb26 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "start-span-streamed", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 0211fa7f96a9..a5eb7f4edcda 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -9,6 +9,7 @@ import { initAndBind, linkedErrorsIntegration, requestDataIntegration, + spanStreamingIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; import type { CloudflareClientOptions, CloudflareOptions } from './client'; @@ -52,10 +53,15 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined { const flushLock = options.ctx ? makeFlushLock(options.ctx) : undefined; delete options.ctx; + const resolvedIntegrations = getIntegrationsToSetup(options); + if (options.traceLifecycle === 'stream' && !resolvedIntegrations.some(i => i.name === 'SpanStreaming')) { + resolvedIntegrations.push(spanStreamingIntegration()); + } + const clientOptions: CloudflareClientOptions = { ...options, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), - integrations: getIntegrationsToSetup(options), + integrations: resolvedIntegrations, transport: options.transport || makeCloudflareTransport, flushLock, }; diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 2f4ec7844559..2b6cafbf5498 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -1,4 +1,5 @@ import * as SentryCore from '@sentry/core'; +import { getClient } from '@sentry/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { CloudflareClient } from '../src/client'; import { init } from '../src/sdk'; @@ -18,4 +19,29 @@ describe('init', () => { expect(client).toBeDefined(); expect(client).toBeInstanceOf(CloudflareClient); }); + + test('installs SpanStreaming integration when traceLifecycle is "stream"', () => { + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + traceLifecycle: 'stream', + }); + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); + + test("does not install SpanStreaming integration when traceLifecycle is not 'stream'", () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + const client = getClient(); + + expect(client?.getOptions()).toEqual( + expect.objectContaining({ + integrations: expect.not.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]), + }), + ); + }); }); diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index a40055002f57..177c2e91234d 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -9,6 +9,7 @@ import { linkedErrorsIntegration, nodeStackLineParser, requestDataIntegration, + spanStreamingIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; import { DenoClient } from './client'; @@ -95,10 +96,15 @@ export function init(options: DenoOptions = {}): Client { options.defaultIntegrations = getDefaultIntegrations(options); } + const resolvedIntegrations = getIntegrationsToSetup(options); + if (options.traceLifecycle === 'stream' && !resolvedIntegrations.some(i => i.name === 'SpanStreaming')) { + resolvedIntegrations.push(spanStreamingIntegration()); + } + const clientOptions: ServerRuntimeClientOptions = { ...options, stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser), - integrations: getIntegrationsToSetup(options), + integrations: resolvedIntegrations, transport: options.transport || makeFetchTransport, }; diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index 7c7c0626cffa..e35aa770c880 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -23,6 +23,7 @@ import { nodeStackLineParser, requestDataIntegration, SDK_VERSION, + spanStreamingIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; import { @@ -98,10 +99,15 @@ export function init(options: VercelEdgeOptions = {}): Client | undefined { options.environment = options.environment || process.env.SENTRY_ENVIRONMENT || getVercelEnv(false) || process.env.NODE_ENV; + const resolvedIntegrations = getIntegrationsToSetup(options); + if (options.traceLifecycle === 'stream' && !resolvedIntegrations.some(i => i.name === 'SpanStreaming')) { + resolvedIntegrations.push(spanStreamingIntegration()); + } + const client = new VercelEdgeClient({ ...options, stackParser: stackParserFromStackParserOptions(options.stackParser || nodeStackParser), - integrations: getIntegrationsToSetup(options), + integrations: resolvedIntegrations, transport: options.transport || makeEdgeTransport, }); // The client is on the current scope, from where it generally is inherited From 79505f2a09c0969319dcbaf6d8390893dbc1c432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Wed, 28 Jan 2026 11:24:31 +0100 Subject: [PATCH 2/8] feat(test-utils): Add a way to wait for single spans for Span streaming (#18986) How it should be used is in the JSDoc. It worked quite well for my Cloudflare tests Closes #18987 (added automatically) --- .../test-utils/src/event-proxy-server.ts | 200 ++++++++++++++++++ dev-packages/test-utils/src/index.ts | 4 + 2 files changed, 204 insertions(+) diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index 9c411c3fc015..14942c951507 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -6,6 +6,8 @@ import type { SerializedMetric, SerializedMetricContainer, SerializedSession, + SpanV2Envelope, + SpanV2JSON, } from '@sentry/core'; import { parseEnvelope } from '@sentry/core'; import * as fs from 'fs'; @@ -427,6 +429,204 @@ export function waitForMetric( }); } +/** + * Check if an envelope item is a Span V2 container item. + */ +function isSpanV2EnvelopeItem( + envelopeItem: EnvelopeItem, +): envelopeItem is [ + { type: 'span'; content_type: 'application/vnd.sentry.items.span.v2+json'; item_count: number }, + { items: SpanV2JSON[] }, +] { + const [header] = envelopeItem; + return ( + header.type === 'span' && + 'content_type' in header && + header.content_type === 'application/vnd.sentry.items.span.v2+json' + ); +} + +/** + * Wait for a Span V2 envelope to be sent. + * Returns the first Span V2 envelope that is sent that matches the callback. + * If no callback is provided, returns the first Span V2 envelope that is sent. + * + * @example + * ```ts + * const envelope = await waitForSpanV2Envelope(PROXY_SERVER_NAME); + * const spans = envelope[1][0][1].items; + * expect(spans.length).toBeGreaterThan(0); + * ``` + * + * @example + * ```ts + * // With a filter callback + * const envelope = await waitForSpanV2Envelope(PROXY_SERVER_NAME, envelope => { + * return envelope[1][0][1].items.length > 5; + * }); + * ``` + */ +export function waitForSpanV2Envelope( + proxyServerName: string, + callback?: (spanEnvelope: SpanV2Envelope) => Promise | boolean, +): Promise { + const timestamp = getNanosecondTimestamp(); + return new Promise((resolve, reject) => { + waitForRequest( + proxyServerName, + async eventData => { + const envelope = eventData.envelope; + const envelopeItems = envelope[1]; + + // Check if this is a Span V2 envelope by looking for a Span V2 item + const hasSpanV2Item = envelopeItems.some(item => isSpanV2EnvelopeItem(item)); + if (!hasSpanV2Item) { + return false; + } + + const spanV2Envelope = envelope as SpanV2Envelope; + + if (callback) { + return callback(spanV2Envelope); + } + + return true; + }, + timestamp, + ) + .then(eventData => resolve(eventData.envelope as SpanV2Envelope)) + .catch(reject); + }); +} + +/** + * Wait for a single Span V2 to be sent that matches the callback. + * Returns the first Span V2 that is sent that matches the callback. + * If no callback is provided, returns the first Span V2 that is sent. + * + * @example + * ```ts + * const span = await waitForSpanV2(PROXY_SERVER_NAME, span => { + * return span.name === 'GET /api/users'; + * }); + * expect(span.status).toBe('ok'); + * ``` + * + * @example + * ```ts + * // Using the getSpanV2Op helper + * const span = await waitForSpanV2(PROXY_SERVER_NAME, span => { + * return getSpanV2Op(span) === 'http.client'; + * }); + * ``` + */ +export function waitForSpanV2( + proxyServerName: string, + callback: (span: SpanV2JSON) => Promise | boolean, +): Promise { + const timestamp = getNanosecondTimestamp(); + return new Promise((resolve, reject) => { + waitForRequest( + proxyServerName, + async eventData => { + const envelope = eventData.envelope; + const envelopeItems = envelope[1]; + + for (const envelopeItem of envelopeItems) { + if (!isSpanV2EnvelopeItem(envelopeItem)) { + return false + } + + const spans = envelopeItem[1].items; + + for (const span of spans) { + if (await callback(span)) { + resolve(span); + return true; + } + } + } + return false; + }, + timestamp, + ).catch(reject); + }); +} + +/** + * Wait for Span V2 spans to be sent. Returns all matching spans from the first envelope that has at least one match. + * The callback receives individual spans (not an array), making it consistent with `waitForSpanV2`. + * If no callback is provided, returns all spans from the first Span V2 envelope. + * + * @example + * ```ts + * // Get all spans from the first envelope + * const spans = await waitForSpansV2(PROXY_SERVER_NAME); + * expect(spans.length).toBeGreaterThan(0); + * ``` + * + * @example + * ```ts + * // Filter for specific spans (same callback style as waitForSpanV2) + * const httpSpans = await waitForSpansV2(PROXY_SERVER_NAME, span => { + * return getSpanV2Op(span) === 'http.client'; + * }); + * expect(httpSpans.length).toBe(2); + * ``` + */ +export function waitForSpansV2( + proxyServerName: string, + callback?: (span: SpanV2JSON) => Promise | boolean, +): Promise { + const timestamp = getNanosecondTimestamp(); + return new Promise((resolve, reject) => { + waitForRequest( + proxyServerName, + async eventData => { + const envelope = eventData.envelope; + const envelopeItems = envelope[1]; + + for (const envelopeItem of envelopeItems) { + if (isSpanV2EnvelopeItem(envelopeItem)) { + const spans = envelopeItem[1].items; + if (callback) { + const matchingSpans: SpanV2JSON[] = []; + for (const span of spans) { + if (await callback(span)) { + matchingSpans.push(span); + } + } + if (matchingSpans.length > 0) { + resolve(matchingSpans); + return true; + } + } else { + resolve(spans); + return true; + } + } + } + return false; + }, + timestamp, + ).catch(reject); + }); +} + +/** + * Helper to get the span operation from a Span V2 JSON object. + * + * @example + * ```ts + * const span = await waitForSpanV2(PROXY_SERVER_NAME, span => { + * return getSpanV2Op(span) === 'http.client'; + * }); + * ``` + */ +export function getSpanV2Op(span: SpanV2JSON): string | undefined { + return span.attributes?.['sentry.op']?.type === 'string' ? span.attributes['sentry.op'].value : undefined; +} + const TEMP_FILE_PREFIX = 'event-proxy-server-'; async function registerCallbackServerPort(serverName: string, port: string): Promise { diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 749cbdbdd663..c34808e9e5a1 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -8,6 +8,10 @@ export { waitForSession, waitForPlainRequest, waitForMetric, + waitForSpanV2, + waitForSpansV2, + waitForSpanV2Envelope, + getSpanV2Op, } from './event-proxy-server'; export { getPlaywrightConfig } from './playwright-config'; From 1c57dafd83605a14923e9fa75b8d8eaa688ca5cc Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 8 Apr 2026 18:10:06 +0200 Subject: [PATCH 3/8] adjust e2e test helpers to reflect current naming scheme --- .../test-utils/src/event-proxy-server.ts | 42 +++++++++---------- dev-packages/test-utils/src/index.ts | 8 ++-- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index 14942c951507..d02005add102 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -6,8 +6,8 @@ import type { SerializedMetric, SerializedMetricContainer, SerializedSession, - SpanV2Envelope, - SpanV2JSON, + SerializedStreamedSpan, + StreamedSpanEnvelope, } from '@sentry/core'; import { parseEnvelope } from '@sentry/core'; import * as fs from 'fs'; @@ -432,11 +432,11 @@ export function waitForMetric( /** * Check if an envelope item is a Span V2 container item. */ -function isSpanV2EnvelopeItem( +function isStreamedSpanEnvelopeItem( envelopeItem: EnvelopeItem, ): envelopeItem is [ { type: 'span'; content_type: 'application/vnd.sentry.items.span.v2+json'; item_count: number }, - { items: SpanV2JSON[] }, + { items: SerializedStreamedSpan[] }, ] { const [header] = envelopeItem; return ( @@ -466,10 +466,10 @@ function isSpanV2EnvelopeItem( * }); * ``` */ -export function waitForSpanV2Envelope( +export function waitForStreamedSpanEnvelope( proxyServerName: string, - callback?: (spanEnvelope: SpanV2Envelope) => Promise | boolean, -): Promise { + callback?: (spanEnvelope: StreamedSpanEnvelope) => Promise | boolean, +): Promise { const timestamp = getNanosecondTimestamp(); return new Promise((resolve, reject) => { waitForRequest( @@ -479,12 +479,12 @@ export function waitForSpanV2Envelope( const envelopeItems = envelope[1]; // Check if this is a Span V2 envelope by looking for a Span V2 item - const hasSpanV2Item = envelopeItems.some(item => isSpanV2EnvelopeItem(item)); + const hasSpanV2Item = envelopeItems.some(item => isStreamedSpanEnvelopeItem(item)); if (!hasSpanV2Item) { return false; } - const spanV2Envelope = envelope as SpanV2Envelope; + const spanV2Envelope = envelope as StreamedSpanEnvelope; if (callback) { return callback(spanV2Envelope); @@ -494,7 +494,7 @@ export function waitForSpanV2Envelope( }, timestamp, ) - .then(eventData => resolve(eventData.envelope as SpanV2Envelope)) + .then(eventData => resolve(eventData.envelope as StreamedSpanEnvelope)) .catch(reject); }); } @@ -520,10 +520,10 @@ export function waitForSpanV2Envelope( * }); * ``` */ -export function waitForSpanV2( +export function waitForStreamedSpan( proxyServerName: string, - callback: (span: SpanV2JSON) => Promise | boolean, -): Promise { + callback: (span: SerializedStreamedSpan) => Promise | boolean, +): Promise { const timestamp = getNanosecondTimestamp(); return new Promise((resolve, reject) => { waitForRequest( @@ -533,8 +533,8 @@ export function waitForSpanV2( const envelopeItems = envelope[1]; for (const envelopeItem of envelopeItems) { - if (!isSpanV2EnvelopeItem(envelopeItem)) { - return false + if (!isStreamedSpanEnvelopeItem(envelopeItem)) { + return false; } const spans = envelopeItem[1].items; @@ -574,10 +574,10 @@ export function waitForSpanV2( * expect(httpSpans.length).toBe(2); * ``` */ -export function waitForSpansV2( +export function waitForStreamedSpans( proxyServerName: string, - callback?: (span: SpanV2JSON) => Promise | boolean, -): Promise { + callback?: (span: SerializedStreamedSpan) => Promise | boolean, +): Promise { const timestamp = getNanosecondTimestamp(); return new Promise((resolve, reject) => { waitForRequest( @@ -587,10 +587,10 @@ export function waitForSpansV2( const envelopeItems = envelope[1]; for (const envelopeItem of envelopeItems) { - if (isSpanV2EnvelopeItem(envelopeItem)) { + if (isStreamedSpanEnvelopeItem(envelopeItem)) { const spans = envelopeItem[1].items; if (callback) { - const matchingSpans: SpanV2JSON[] = []; + const matchingSpans: SerializedStreamedSpan[] = []; for (const span of spans) { if (await callback(span)) { matchingSpans.push(span); @@ -623,7 +623,7 @@ export function waitForSpansV2( * }); * ``` */ -export function getSpanV2Op(span: SpanV2JSON): string | undefined { +export function getSpanOp(span: SerializedStreamedSpan): string | undefined { return span.attributes?.['sentry.op']?.type === 'string' ? span.attributes['sentry.op'].value : undefined; } diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index c34808e9e5a1..54e5d11749b4 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -8,10 +8,10 @@ export { waitForSession, waitForPlainRequest, waitForMetric, - waitForSpanV2, - waitForSpansV2, - waitForSpanV2Envelope, - getSpanV2Op, + waitForStreamedSpan, + waitForStreamedSpans, + waitForStreamedSpanEnvelope, + getSpanOp, } from './event-proxy-server'; export { getPlaywrightConfig } from './playwright-config'; From a0b77af8b739ec6e33fdd5e9453845ae4ffc30d2 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 8 Apr 2026 19:15:42 +0200 Subject: [PATCH 4/8] add deno e2e test app --- .../test-applications/deno-streamed/.npmrc | 2 + .../test-applications/deno-streamed/deno.json | 11 + .../deno-streamed/package.json | 25 ++ .../deno-streamed/playwright.config.mjs | 8 + .../deno-streamed/src/app.ts | 78 ++++ .../deno-streamed/start-event-proxy.mjs | 6 + .../deno-streamed/tests/spans.test.ts | 349 ++++++++++++++++++ .../test-utils/src/event-proxy-server.ts | 19 +- 8 files changed, 485 insertions(+), 13 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/deno.json create mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/package.json create mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/.npmrc b/dev-packages/e2e-tests/test-applications/deno-streamed/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/deno.json b/dev-packages/e2e-tests/test-applications/deno-streamed/deno.json new file mode 100644 index 000000000000..35242c740171 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/deno.json @@ -0,0 +1,11 @@ +{ + "imports": { + "@sentry/deno": "npm:@sentry/deno", + "@sentry/core": "npm:@sentry/core", + "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", + "ai": "npm:ai@^3.0.0", + "ai/test": "npm:ai@^3.0.0/test", + "zod": "npm:zod@^3.22.4" + }, + "nodeModulesDir": "manual" +} diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/package.json b/dev-packages/e2e-tests/test-applications/deno-streamed/package.json new file mode 100644 index 000000000000..70a20db2de05 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/package.json @@ -0,0 +1,25 @@ +{ + "name": "deno-streamed-app", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "deno run --allow-net --allow-env --allow-read src/app.ts", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/deno": "latest || *", + "@opentelemetry/api": "^1.9.0", + "ai": "^3.0.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno-streamed/playwright.config.mjs new file mode 100644 index 000000000000..3d3ab7d8df02 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts new file mode 100644 index 000000000000..d6e96c83dd02 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts @@ -0,0 +1,78 @@ +import { trace } from '@opentelemetry/api'; + +// Simulate a pre-existing OTel provider (like Supabase Edge Runtime registers +// before user code runs). Without trace.disable() in Sentry's setup, this would +// cause setGlobalTracerProvider to be a no-op, silently dropping all OTel spans. +const fakeProvider = { + getTracer: () => ({ + startSpan: () => ({ end: () => {}, setAttributes: () => {} }), + startActiveSpan: (_name: string, fn: Function) => fn({ end: () => {}, setAttributes: () => {} }), + }), +}; +trace.setGlobalTracerProvider(fakeProvider as any); + +// Sentry.init() must call trace.disable() to clear the fake provider above +import * as Sentry from '@sentry/deno'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; + +Sentry.init({ + environment: 'qa', + dsn: Deno.env.get('E2E_TEST_DSN'), + debug: !!Deno.env.get('DEBUG'), + tunnel: 'http://localhost:3031/', + traceLifecycle: 'stream', + tracesSampleRate: 1, + sendDefaultPii: true, + enableLogs: true, +}); + +const port = 3030; + +Deno.serve({ port }, async (req: Request) => { + const url = new URL(req.url); + + if (url.pathname === '/test-success') { + return new Response(JSON.stringify({ version: 'v1' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test Sentry.startSpan — uses Sentry's internal pipeline + if (url.pathname === '/test-sentry-span') { + Sentry.startSpan({ name: 'test-sentry-span' }, () => { + // noop + }); + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test interop: OTel span inside a Sentry span + if (url.pathname === '/test-interop') { + Sentry.startSpan({ name: 'sentry-parent' }, () => { + const tracer = trace.getTracer('test-tracer'); + const span = tracer.startSpan('otel-child'); + span.end(); + }); + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test outbound fetch instrumentation + if (url.pathname === '/test-outgoing-fetch') { + const response = await Sentry.startSpan({ name: 'test-outgoing-fetch' }, async () => { + const res = await fetch('http://localhost:3030/test-success'); + return res.json(); + }); + return new Response(JSON.stringify(response), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response('Not found', { status: 404 }); +}); + +console.log(`Deno test app listening on port ${port}`); diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/deno-streamed/start-event-proxy.mjs new file mode 100644 index 000000000000..a0c7bfc7222f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'deno-streamed', +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts new file mode 100644 index 000000000000..08d9b1d7ce56 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts @@ -0,0 +1,349 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +const SEGMENT_SPAN = { + attributes: { + 'client.address': { + type: 'string', + value: expect.any(String), + }, + 'client.port': { + type: 'integer', + value: expect.any(Number), + }, + 'http.request.header.accept': { + type: 'string', + value: '*/*', + }, + 'http.request.header.accept_encoding': { + type: 'string', + value: 'gzip, deflate', + }, + 'http.request.header.accept_language': { + type: 'string', + value: '*', + }, + 'http.request.header.connection': { + type: 'string', + value: 'keep-alive', + }, + 'http.request.header.host': { + type: 'string', + value: expect.stringMatching(/^localhost:\d+$/), + }, + '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.header.content_type': { + type: 'string', + value: 'application/json', + }, + 'http.response.status_code': { + type: 'integer', + value: expect.any(Number), + }, + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.op': { + type: 'string', + value: 'http.server', + }, + 'sentry.origin': { + type: 'string', + value: 'auto.http.deno', + }, + 'sentry.sample_rate': { + type: 'integer', + value: 1, + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET /test-sentry-span', + }, + 'sentry.source': { + type: 'string', + value: 'url', + }, + 'sentry.span.source': { + type: 'string', + value: 'url', + }, + 'server.address': { + type: 'string', + value: expect.any(String), + }, + 'url.full': { + type: 'string', + value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-sentry-span$/), + }, + 'url.path': { + type: 'string', + value: '/test-sentry-span', + }, + 'url.port': { + type: 'string', + value: expect.any(String), + }, + 'url.scheme': { + type: 'string', + value: 'http:', + }, + 'user_agent.original': { + type: 'string', + value: 'node', + }, + }, + end_timestamp: expect.any(Number), + is_segment: true, + name: 'GET /test-sentry-span', + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), +}; + +test('Sends streamed spans (http.server and manual with Sentry.startSpan)', async ({ baseURL }) => { + const spansPromise = waitForStreamedSpans('deno-streamed', spans => { + return spans.some(span => span.name === 'test-sentry-span'); + }); + + await fetch(`${baseURL}/test-sentry-span`); + + const spans = await spansPromise; + expect(spans).toHaveLength(2); + + expect(spans).toEqual([ + { + attributes: { + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET /test-sentry-span', + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'test-sentry-span', + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }, + SEGMENT_SPAN, + ]); +}); + +test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) => { + const spansPromise = waitForStreamedSpans('deno-streamed', spans => { + return spans.some(span => span.name === 'sentry-parent'); + }); + + await fetch(`${baseURL}/test-interop`); + + const spans = await spansPromise; + + expect(spans).toHaveLength(3); + + const httpServerSpan = spans.find(span => getSpanOp(span) === 'http.server'); + expect(httpServerSpan).toEqual({ + ...SEGMENT_SPAN, + name: 'GET /test-interop', + attributes: { + ...SEGMENT_SPAN.attributes, + 'sentry.segment.name': { type: 'string', value: 'GET /test-interop' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-interop$/) }, + 'url.path': { type: 'string', value: '/test-interop' }, + }, + }); + // Verify the OTel span is a child of the Sentry span + const sentrySpan = spans.find(span => span.name === 'sentry-parent'); + const otelSpan = spans.find(span => span.name === 'otel-child'); + + expect(otelSpan!.parent_span_id).toBe(sentrySpan!.span_id); + + expect(sentrySpan).toEqual({ + attributes: { + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET /test-interop', + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'sentry-parent', + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: httpServerSpan!.trace_id, + }); + + expect(otelSpan).toEqual({ + attributes: { + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET /test-interop', + }, + 'sentry.deno_tracer': { + type: 'boolean', + value: true, + }, + 'sentry.op': { + type: 'string', + value: 'otel.span', // This looks fishy! + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'otel-child', + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: httpServerSpan!.trace_id, + }); +}); + +test('Outbound fetch inside Sentry span creates transaction', async ({ baseURL }) => { + const spansPromise = waitForStreamedSpans('deno-streamed', spans => { + return spans.some(span => span.name === 'test-outgoing-fetch'); + }); + + await fetch(`${baseURL}/test-outgoing-fetch`); + + const spans = await spansPromise; + + expect(spans).toHaveLength(2); + + expect(spans).toEqual([ + { + attributes: { + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET /test-outgoing-fetch', + }, + }, + is_segment: false, + name: 'test-outgoing-fetch', + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }, + { + ...SEGMENT_SPAN, + name: 'GET /test-outgoing-fetch', + attributes: { + ...SEGMENT_SPAN.attributes, + 'sentry.segment.name': { type: 'string', value: 'GET /test-outgoing-fetch' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-outgoing-fetch$/) }, + 'url.path': { type: 'string', value: '/test-outgoing-fetch' }, + }, + }, + ]); +}); diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index d02005add102..1afa5213a87f 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -554,8 +554,7 @@ export function waitForStreamedSpan( } /** - * Wait for Span V2 spans to be sent. Returns all matching spans from the first envelope that has at least one match. - * The callback receives individual spans (not an array), making it consistent with `waitForSpanV2`. + * Wait for Span V2 spans to be sent. Returns all spans from the envelope for which the callback returns true. * If no callback is provided, returns all spans from the first Span V2 envelope. * * @example @@ -568,15 +567,15 @@ export function waitForStreamedSpan( * @example * ```ts * // Filter for specific spans (same callback style as waitForSpanV2) - * const httpSpans = await waitForSpansV2(PROXY_SERVER_NAME, span => { - * return getSpanV2Op(span) === 'http.client'; + * const httpSpans = await waitForSpansV2(PROXY_SERVER_NAME, spans => { + * return spans.some(span => getSpanV2Op(span) === 'http.client'); * }); * expect(httpSpans.length).toBe(2); * ``` */ export function waitForStreamedSpans( proxyServerName: string, - callback?: (span: SerializedStreamedSpan) => Promise | boolean, + callback?: (spans: SerializedStreamedSpan[]) => Promise | boolean, ): Promise { const timestamp = getNanosecondTimestamp(); return new Promise((resolve, reject) => { @@ -590,14 +589,8 @@ export function waitForStreamedSpans( if (isStreamedSpanEnvelopeItem(envelopeItem)) { const spans = envelopeItem[1].items; if (callback) { - const matchingSpans: SerializedStreamedSpan[] = []; - for (const span of spans) { - if (await callback(span)) { - matchingSpans.push(span); - } - } - if (matchingSpans.length > 0) { - resolve(matchingSpans); + if (await callback(spans)) { + resolve(spans); return true; } } else { From 40dddeb0c226e805f075282279e4b8e31288d795 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 8 Apr 2026 19:16:09 +0200 Subject: [PATCH 5/8] bit of cleanup --- .../e2e-tests/test-applications/deno-streamed/src/app.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts index d6e96c83dd02..df952fd1ba6c 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts @@ -13,9 +13,6 @@ trace.setGlobalTracerProvider(fakeProvider as any); // Sentry.init() must call trace.disable() to clear the fake provider above import * as Sentry from '@sentry/deno'; -import { generateText } from 'ai'; -import { MockLanguageModelV1 } from 'ai/test'; -import { z } from 'zod'; Sentry.init({ environment: 'qa', From 3fde21fe42da7d21f56c64acbcb5d26bd141fb01 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 8 Apr 2026 19:21:16 +0200 Subject: [PATCH 6/8] . --- .../test-applications/deno-streamed/tests/spans.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts index 08d9b1d7ce56..8c939dbef079 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts @@ -287,7 +287,7 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) }); }); -test('Outbound fetch inside Sentry span creates transaction', async ({ baseURL }) => { +test('Outbound fetch inside Sentry span creates span ... does it really?', async ({ baseURL }) => { const spansPromise = waitForStreamedSpans('deno-streamed', spans => { return spans.some(span => span.name === 'test-outgoing-fetch'); }); From c8791756ffd33e78fda1b2c503e5524a277c015b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 10 Apr 2026 14:19:32 +0200 Subject: [PATCH 7/8] remove unnecessary test --- .../deno-streamed/src/app.ts | 25 +++---- .../deno-streamed/tests/spans.test.ts | 65 ------------------- 2 files changed, 8 insertions(+), 82 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts index df952fd1ba6c..206eb7f6f387 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts @@ -27,20 +27,21 @@ Sentry.init({ const port = 3030; +function flushDeferred() { + setTimeout(() => { + Sentry.flush(); + }, 100); +} + Deno.serve({ port }, async (req: Request) => { const url = new URL(req.url); - if (url.pathname === '/test-success') { - return new Response(JSON.stringify({ version: 'v1' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - // Test Sentry.startSpan — uses Sentry's internal pipeline if (url.pathname === '/test-sentry-span') { Sentry.startSpan({ name: 'test-sentry-span' }, () => { // noop }); + flushDeferred(); return new Response(JSON.stringify({ status: 'ok' }), { headers: { 'Content-Type': 'application/json' }, }); @@ -53,22 +54,12 @@ Deno.serve({ port }, async (req: Request) => { const span = tracer.startSpan('otel-child'); span.end(); }); + flushDeferred(); return new Response(JSON.stringify({ status: 'ok' }), { headers: { 'Content-Type': 'application/json' }, }); } - // Test outbound fetch instrumentation - if (url.pathname === '/test-outgoing-fetch') { - const response = await Sentry.startSpan({ name: 'test-outgoing-fetch' }, async () => { - const res = await fetch('http://localhost:3030/test-success'); - return res.json(); - }); - return new Response(JSON.stringify(response), { - headers: { 'Content-Type': 'application/json' }, - }); - } - return new Response('Not found', { status: 404 }); }); diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts index 8c939dbef079..023429b07f41 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts @@ -271,10 +271,6 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) type: 'boolean', value: true, }, - 'sentry.op': { - type: 'string', - value: 'otel.span', // This looks fishy! - }, }, end_timestamp: expect.any(Number), is_segment: false, @@ -286,64 +282,3 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) trace_id: httpServerSpan!.trace_id, }); }); - -test('Outbound fetch inside Sentry span creates span ... does it really?', async ({ baseURL }) => { - const spansPromise = waitForStreamedSpans('deno-streamed', spans => { - return spans.some(span => span.name === 'test-outgoing-fetch'); - }); - - await fetch(`${baseURL}/test-outgoing-fetch`); - - const spans = await spansPromise; - - expect(spans).toHaveLength(2); - - expect(spans).toEqual([ - { - attributes: { - 'sentry.environment': { - type: 'string', - value: 'qa', - }, - 'sentry.origin': { - type: 'string', - value: 'manual', - }, - 'sentry.sdk.name': { - type: 'string', - value: 'sentry.javascript.deno', - }, - 'sentry.sdk.version': { - type: 'string', - value: expect.any(String), - }, - 'sentry.segment.id': { - type: 'string', - value: expect.stringMatching(/^[\da-f]{16}$/), - }, - 'sentry.segment.name': { - type: 'string', - value: 'GET /test-outgoing-fetch', - }, - }, - is_segment: false, - name: 'test-outgoing-fetch', - parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), - span_id: expect.stringMatching(/^[\da-f]{16}$/), - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - trace_id: expect.stringMatching(/^[\da-f]{32}$/), - }, - { - ...SEGMENT_SPAN, - name: 'GET /test-outgoing-fetch', - attributes: { - ...SEGMENT_SPAN.attributes, - 'sentry.segment.name': { type: 'string', value: 'GET /test-outgoing-fetch' }, - 'url.full': { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-outgoing-fetch$/) }, - 'url.path': { type: 'string', value: '/test-outgoing-fetch' }, - }, - }, - ]); -}); From 195a7b969f0371791ba51401ac70b2276c303c6f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 10 Apr 2026 14:36:32 +0200 Subject: [PATCH 8/8] fix deno-streamed e2e test in ci --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cf00b5d00435..bc045e861443 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -970,7 +970,7 @@ jobs: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno - if: matrix.test-application == 'deno' + if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' uses: denoland/setup-deno@v2.0.3 with: deno-version: v2.1.5