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/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..df952fd1ba6c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts @@ -0,0 +1,75 @@ +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'; + +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..8c939dbef079 --- /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 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' }, + }, + }, + ]); +}); diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index 9c411c3fc015..1afa5213a87f 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, + SerializedStreamedSpan, + StreamedSpanEnvelope, } from '@sentry/core'; import { parseEnvelope } from '@sentry/core'; import * as fs from 'fs'; @@ -427,6 +429,197 @@ export function waitForMetric( }); } +/** + * Check if an envelope item is a Span V2 container item. + */ +function isStreamedSpanEnvelopeItem( + envelopeItem: EnvelopeItem, +): envelopeItem is [ + { type: 'span'; content_type: 'application/vnd.sentry.items.span.v2+json'; item_count: number }, + { items: SerializedStreamedSpan[] }, +] { + 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 waitForStreamedSpanEnvelope( + proxyServerName: string, + callback?: (spanEnvelope: StreamedSpanEnvelope) => 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 => isStreamedSpanEnvelopeItem(item)); + if (!hasSpanV2Item) { + return false; + } + + const spanV2Envelope = envelope as StreamedSpanEnvelope; + + if (callback) { + return callback(spanV2Envelope); + } + + return true; + }, + timestamp, + ) + .then(eventData => resolve(eventData.envelope as StreamedSpanEnvelope)) + .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 waitForStreamedSpan( + proxyServerName: string, + callback: (span: SerializedStreamedSpan) => 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 (!isStreamedSpanEnvelopeItem(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 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 + * ```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, spans => { + * return spans.some(span => getSpanV2Op(span) === 'http.client'); + * }); + * expect(httpSpans.length).toBe(2); + * ``` + */ +export function waitForStreamedSpans( + proxyServerName: string, + callback?: (spans: SerializedStreamedSpan[]) => 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 (isStreamedSpanEnvelopeItem(envelopeItem)) { + const spans = envelopeItem[1].items; + if (callback) { + if (await callback(spans)) { + resolve(spans); + 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 getSpanOp(span: SerializedStreamedSpan): 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..54e5d11749b4 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, + waitForStreamedSpan, + waitForStreamedSpans, + waitForStreamedSpanEnvelope, + getSpanOp, } from './event-proxy-server'; export { getPlaywrightConfig } from './playwright-config'; 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