From 2bcb80c20d9ec0c44b0551dac3a0e45040f02806 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Mon, 27 Jul 2026 15:42:54 +0200 Subject: [PATCH 1/2] feat(browser)!: Use `http.client` span op for streamed fetch bodies Streaming changes response behaviour, not the operation category. The sibling span is now marked with `http.response.body.streaming` instead of an `http.client.stream` op. Ref: JS-3105 Co-Authored-By: Claude Opus 5 (1M context) --- .../test.ts | 19 +++++++++++++++---- .../react-router-6/tests/sse.test.ts | 17 ++++++++++++----- .../integrations/fetchStreamPerformance.ts | 12 +++++++----- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts index c8261692dbfa..c32692a0b9d7 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts @@ -3,10 +3,11 @@ import type { AddressInfo } from 'net'; import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../utils/helpers'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; sentryTest( - 'creates an http.client.stream sibling span when fetchStreamPerformanceIntegration is used', + 'creates a streaming http.client sibling span when fetchStreamPerformanceIntegration is used', async ({ getLocalTestUrl, page }) => { sentryTest.skip(shouldSkipTracingTest()); @@ -36,9 +37,18 @@ sentryTest( const url = await getLocalTestUrl({ testDir: __dirname }); - // Wait for each span type separately since they may arrive in different envelopes - const httpSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'http.client'); - const streamSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'http.client.stream'); + // Wait for each span type separately since they may arrive in different envelopes. + // Both spans share the `http.client` op, so the streaming attribute is what tells them apart. + const isStreamSpan = (span: SerializedStreamedSpan): boolean => + span.attributes['http.response.body.streaming']?.value === true; + const httpSpanPromise = waitForStreamedSpan( + page, + span => getSpanOp(span) === 'http.client' && !isStreamSpan(span), + ); + const streamSpanPromise = waitForStreamedSpan( + page, + span => getSpanOp(span) === 'http.client' && isStreamSpan(span), + ); await page.goto(url); @@ -55,6 +65,7 @@ sentryTest( 'http.method': { type: 'string', value: 'GET' }, 'url.full': { type: 'string', value: 'http://sentry-test-site.example/delayed' }, type: { type: 'string', value: 'fetch' }, + 'http.response.body.streaming': { type: 'boolean', value: true }, }), }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts index a73642b20b6f..648c9b869fd4 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6/tests/sse.test.ts @@ -13,8 +13,10 @@ test('Waits for sse streaming when creating spans', async ({ page }) => { const rootSpan = await transactionPromise; const sseFetchCall = rootSpan.spans?.filter(span => span.description === 'sse fetch call')[0]!; - const httpGet = rootSpan.spans?.filter(span => span.description === 'GET http://localhost:8080/sse')[0]!; - const httpStream = rootSpan.spans?.filter(span => span.op === 'http.client.stream')[0]!; + const httpGet = rootSpan.spans?.filter( + span => span.description === 'GET http://localhost:8080/sse' && !span.data?.['http.response.body.streaming'], + )[0]!; + const httpStream = rootSpan.spans?.filter(span => span.data?.['http.response.body.streaming'])[0]!; expect(sseFetchCall).toBeDefined(); expect(httpGet).toBeDefined(); @@ -26,7 +28,7 @@ test('Waits for sse streaming when creating spans', async ({ page }) => { // http.client span ends at header arrival (~0s) const httpGetDuration = Math.round((httpGet.timestamp as number) - httpGet.start_timestamp); - // body streaming duration is captured in the sibling http.client.stream span (~2s) + // body streaming duration is captured in the sibling streaming http.client span (~2s) const streamDuration = Math.round((httpStream.timestamp as number) - httpStream.start_timestamp); expect(resolveDuration).toBe(0); @@ -46,7 +48,9 @@ test('Waits for sse streaming when sse has been explicitly aborted', async ({ pa const rootSpan = await transactionPromise; const sseFetchCall = rootSpan.spans?.filter(span => span.description === 'sse fetch call')[0]!; - const httpGet = rootSpan.spans?.filter(span => span.description === 'GET http://localhost:8080/sse')[0]!; + const httpGet = rootSpan.spans?.filter( + span => span.description === 'GET http://localhost:8080/sse' && !span.data?.['http.response.body.streaming'], + )[0]!; expect(sseFetchCall).toBeDefined(); expect(httpGet).toBeDefined(); @@ -86,7 +90,10 @@ test('Aborts when stream takes longer than 5s, by not updating the span duration const rootSpan = await transactionPromise; const sseFetchCall = rootSpan.spans?.filter(span => span.description === 'sse fetch call')[0]!; - const httpGet = rootSpan.spans?.filter(span => span.description === 'GET http://localhost:8080/sse-timeout')[0]!; + const httpGet = rootSpan.spans?.filter( + span => + span.description === 'GET http://localhost:8080/sse-timeout' && !span.data?.['http.response.body.streaming'], + )[0]!; expect(sseFetchCall).toBeDefined(); expect(httpGet).toBeDefined(); diff --git a/packages/browser/src/integrations/fetchStreamPerformance.ts b/packages/browser/src/integrations/fetchStreamPerformance.ts index dd3342a31cc7..69d8b853826c 100644 --- a/packages/browser/src/integrations/fetchStreamPerformance.ts +++ b/packages/browser/src/integrations/fetchStreamPerformance.ts @@ -1,4 +1,5 @@ -import { URL_FULL } from '@sentry/conventions/attributes'; +import { SENTRY_OP, URL_FULL } from '@sentry/conventions/attributes'; +import { WEB_SERVER_HTTP_CLIENT_SPAN_OP } from '@sentry/conventions/op'; import type { IntegrationFn, Span } from '@sentry/core'; import { addFetchEndInstrumentationHandler, @@ -6,7 +7,6 @@ import { defineIntegration, getSanitizedUrlStringFromUrlObject, parseStringToURLObject, - SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, stripDataUrlContent, @@ -21,7 +21,8 @@ const STREAM_RESOLVE_FALLBACK_MS = 90_000; const STREAMING_CONTENT_TYPES = ['text/event-stream', 'application/x-ndjson', 'application/stream+json']; /** - * Tracks streamed fetch response bodies by creating an `http.client.stream` sibling span. + * Tracks streamed fetch response bodies by creating a sibling `http.client` span, + * marked with `http.response.body.streaming`. * * The regular `http.client` span ends when response headers arrive. This integration adds * a span that starts at header arrival and ends when the body fully resolves: @@ -29,7 +30,7 @@ const STREAMING_CONTENT_TYPES = ['text/event-stream', 'application/x-ndjson', 'a * ``` * --------- pageload -------------------------------- * -- http.client -- - * -- http.client.stream ------- + * -- http.client (streaming) -- * ``` */ export const fetchStreamPerformanceIntegration = defineIntegration(() => { @@ -84,7 +85,8 @@ export const fetchStreamPerformanceIntegration = defineIntegration(() => { [URL_FULL]: stripDataUrlContent(url), 'http.method': method, type: 'fetch', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client.stream', + [SENTRY_OP]: WEB_SERVER_HTTP_CLIENT_SPAN_OP, + 'http.response.body.streaming': true, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.stream', }, }); From d7183bf56de247ddee81061811549a7342eb3363 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Wed, 29 Jul 2026 10:24:14 +0200 Subject: [PATCH 2/2] rename test helper --- .../request/fetch-streamed-track-stream-performance/test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts index c32692a0b9d7..4310c71ccde2 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-streamed-track-stream-performance/test.ts @@ -39,15 +39,15 @@ sentryTest( // Wait for each span type separately since they may arrive in different envelopes. // Both spans share the `http.client` op, so the streaming attribute is what tells them apart. - const isStreamSpan = (span: SerializedStreamedSpan): boolean => + const isHttpStreamSpan = (span: SerializedStreamedSpan): boolean => span.attributes['http.response.body.streaming']?.value === true; const httpSpanPromise = waitForStreamedSpan( page, - span => getSpanOp(span) === 'http.client' && !isStreamSpan(span), + span => getSpanOp(span) === 'http.client' && !isHttpStreamSpan(span), ); const streamSpanPromise = waitForStreamedSpan( page, - span => getSpanOp(span) === 'http.client' && isStreamSpan(span), + span => getSpanOp(span) === 'http.client' && isHttpStreamSpan(span), ); await page.goto(url);