-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat: Emit low-cardinality http.client span names for fetch and XHR #23682
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,12 @@ | ||
| import { HTTP_REQUEST_METHOD, URL_FULL } from '@sentry/conventions/attributes'; | ||
| import { HTTP_REQUEST_METHOD, URL_DOMAIN, URL_FULL } from '@sentry/conventions/attributes'; | ||
| import type { IntegrationFn, Span } from '@sentry/core'; | ||
| import { | ||
| addFetchEndInstrumentationHandler, | ||
| addFetchInstrumentationHandler, | ||
| defineIntegration, | ||
| getSanitizedUrlStringFromUrlObject, | ||
| hasSpanStreamingEnabled, | ||
| isURLObjectRelative, | ||
| parseStringToURLObject, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_OP, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, | ||
|
|
@@ -37,7 +39,7 @@ export const fetchStreamPerformanceIntegration = defineIntegration(() => { | |
| return { | ||
| name: 'FetchStreamPerformance' as const, | ||
|
|
||
| setup() { | ||
| setup(client) { | ||
| // End the stream span when the response body finishes resolving | ||
| addFetchEndInstrumentationHandler(handlerData => { | ||
| if (handlerData.response) { | ||
|
|
@@ -78,11 +80,17 @@ export const fetchStreamPerformanceIntegration = defineIntegration(() => { | |
| ? getSanitizedUrlStringFromUrlObject(parsedUrl) | ||
| : url; | ||
|
|
||
| // `http.client.stream` follows the same name rules as `http.client`: with span streaming the | ||
| // URL path is dropped and only the domain is kept. Relative URLs have no domain, and an | ||
| // outgoing request has no route to parameterize. | ||
| const domain = parsedUrl && !isURLObjectRelative(parsedUrl) ? parsedUrl.hostname : undefined; | ||
| const streamedName = domain ? `${method} ${domain}` : method; | ||
| const streamSpan = startInactiveSpan({ | ||
| name: `${method} ${sanitizedUrl}`, | ||
| name: hasSpanStreamingEnabled(client) ? streamedName : `${method} ${sanitizedUrl}`, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. q: The Edit: seems like the PR description covers that part |
||
| startTime: handlerData.endTimestamp, | ||
| attributes: { | ||
| [URL_FULL]: filterCollectedUrl(stripDataUrlContent(url)), | ||
| [URL_DOMAIN]: domain, | ||
| [HTTP_REQUEST_METHOD]: method, | ||
| type: 'fetch', | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client.stream', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| import type { Client, IntegrationFn } from '@sentry/core/browser'; | ||
| import { | ||
| defineIntegration, | ||
| hasSpanStreamingEnabled, | ||
| isObjectLike, | ||
| isString, | ||
| SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, | ||
|
|
@@ -83,8 +84,11 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption | |
| const graphqlBody = getGraphQLRequestPayload(payload); | ||
|
|
||
| if (graphqlBody) { | ||
| const operationInfo = _getGraphQLOperation(graphqlBody); | ||
| span.updateName(`${httpMethod} ${httpUrl} (${operationInfo})`); | ||
| // With span streaming the span already carries a low-cardinality name, so it must not be | ||
| // renamed back to something containing the URL. | ||
| if (!hasSpanStreamingEnabled(client)) { | ||
| span.updateName(`${httpMethod} ${httpUrl} (${_getGraphQLOperation(graphqlBody)})`); | ||
| } | ||
|
|
||
|
Comment on lines
+89
to
92
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: When span streaming is enabled, the GraphQL operation name is not added as an attribute to the span, losing valuable information for tracing and observability. Suggested FixIn Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
| // Handle standard requests - capture the query document when enabled via dataCollection (default true) | ||
| if (isStandardRequest(graphqlBody) && client.getDataCollectionOptions().graphQL.document === true) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,7 +43,14 @@ import { | |
| } from '@sentry/browser-utils'; | ||
| import type { BrowserClient } from '../client'; | ||
| import { baggageHeaderHasSentryValues, createHeadersSafely, getFullURL, isPerformanceResourceTiming } from './utils'; | ||
| import { HTTP_REQUEST_METHOD, SERVER_ADDRESS, URL_FRAGMENT, URL_FULL, URL_QUERY } from '@sentry/conventions/attributes'; | ||
| import { | ||
| HTTP_REQUEST_METHOD, | ||
| SERVER_ADDRESS, | ||
| URL_DOMAIN, | ||
| URL_FRAGMENT, | ||
| URL_FULL, | ||
| URL_QUERY, | ||
| } from '@sentry/conventions/attributes'; | ||
|
|
||
| /** Options for Request Instrumentation */ | ||
| export interface RequestInstrumentationOptions { | ||
|
|
@@ -159,11 +166,16 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial<Re | |
| // so we extend this in here | ||
| if (createdSpan) { | ||
| const fullUrl = getFullURL(handlerData.fetchData.url); | ||
| const host = fullUrl ? parseUrl(fullUrl).host : undefined; | ||
| // `parseUrl` returns the raw authority, which can carry userinfo. Credentials must never reach | ||
| // a span name or attribute. | ||
| const host = fullUrl ? parseUrl(fullUrl).host?.replace(/^.*@/, '') : undefined; | ||
| const sanitizedFullUrl = fullUrl ? stripDataUrlContent(fullUrl) : undefined; | ||
| createdSpan.setAttributes({ | ||
| [URL_FULL]: filterCollectedUrl(sanitizedFullUrl), | ||
| [SERVER_ADDRESS]: host, | ||
| // Unlike `server.address`, `url.domain` excludes the port. `getSpanStartOptions` cannot set it | ||
| // for relative URLs, which only resolve to an origin once `getFullURL` has run. | ||
| [URL_DOMAIN]: host?.replace(/:\d+$/, ''), | ||
| }); | ||
|
|
||
| if (enableHTTPTimings) { | ||
|
|
@@ -369,16 +381,28 @@ function xhrCallback( | |
| // With span streaming, we always emit http.client spans, even without a parent span | ||
| const shouldEmitSpan = hasParent || (!!client && hasSpanStreamingEnabled(client)); | ||
|
|
||
| // `parseUrl` returns the raw authority, which can carry userinfo. Credentials must never reach a span | ||
| // name or attribute. | ||
| const host = parsedUrl?.host?.replace(/^.*@/, ''); | ||
| // Unlike `server.address`, `url.domain` excludes the port. | ||
| const domain = host?.replace(/:\d+$/, ''); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. q: This works differently than in the The other code I mean: const domain = parsedUrl && !isURLObjectRelative(parsedUrl) ? parsedUrl.hostname : undefined; |
||
|
|
||
| // With span streaming, span names have to be low cardinality, so the URL path is dropped and only the | ||
| // domain is kept. `getFullURL` resolves relative URLs against the page origin, so one is almost always | ||
| // known here. Outgoing requests have no route to parameterize. | ||
| const streamedName = domain ? `${method} ${domain}` : method; | ||
|
|
||
| const span = | ||
| shouldCreateSpanResult && shouldEmitSpan | ||
| ? startInactiveSpan({ | ||
| name: `${method} ${urlForSpanName}`, | ||
| name: !!client && hasSpanStreamingEnabled(client) ? streamedName : `${method} ${urlForSpanName}`, | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| attributes: { | ||
| type: 'xhr', | ||
| // eslint-disable-next-line typescript/no-deprecated | ||
| [HTTP_REQUEST_METHOD]: method, | ||
| [URL_FULL]: filterCollectedUrl(sanitizedFullUrl), | ||
| [SERVER_ADDRESS]: parsedUrl?.host, | ||
| [SERVER_ADDRESS]: host, | ||
| [URL_DOMAIN]: domain, | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser', | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.client', | ||
| [URL_QUERY]: filterCollectedUrlQuery(getUrlQuery(parsedUrl?.search)), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import type { Client, HandlerDataFetch } from '@sentry/core/browser'; | ||
| import * as utils from '@sentry/core/browser'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { fetchStreamPerformanceIntegration } from '../../src/integrations/fetchStreamPerformance'; | ||
|
|
||
| describe('fetchStreamPerformanceIntegration', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| /** Runs the integration's fetch handler for a streamed response and returns the `startInactiveSpan` spy. */ | ||
| function trackStreamedFetch(traceLifecycle: 'static' | 'stream', url: string) { | ||
| let fetchHandler: ((data: HandlerDataFetch) => void) | undefined; | ||
| vi.spyOn(utils, 'addFetchInstrumentationHandler').mockImplementation(handler => { | ||
| fetchHandler = handler; | ||
| return () => {}; | ||
| }); | ||
| vi.spyOn(utils, 'addFetchEndInstrumentationHandler').mockImplementation(() => () => {}); | ||
| const startInactiveSpanSpy = vi | ||
| .spyOn(utils, 'startInactiveSpan') | ||
| .mockReturnValue(new utils.SentryNonRecordingSpan()); | ||
|
|
||
| fetchStreamPerformanceIntegration().setup?.({ | ||
| getOptions: () => ({ traceLifecycle }), | ||
| getDataCollectionOptions: () => ({ urlQueryParams: true }), | ||
| } as unknown as Client); | ||
|
|
||
| // A streamed response is detected by a streaming content type and a missing content-length. | ||
| fetchHandler?.({ | ||
| fetchData: { url, method: 'GET' }, | ||
| args: [url], | ||
| startTimestamp: Date.now(), | ||
| endTimestamp: Date.now() + 1, | ||
| response: { headers: new Headers({ 'content-type': 'text/event-stream' }) }, | ||
| } as unknown as HandlerDataFetch); | ||
|
|
||
| return startInactiveSpanSpy; | ||
| } | ||
|
|
||
| it('drops the URL path but keeps the domain with span streaming enabled', () => { | ||
| expect(trackStreamedFetch('stream', 'https://api.example.com/v1/chat?stream=1')).toHaveBeenCalledWith( | ||
| expect.objectContaining({ name: 'GET api.example.com' }), | ||
| ); | ||
| }); | ||
|
|
||
| it('falls back to the request method for a relative URL, which has no domain', () => { | ||
| expect(trackStreamedFetch('stream', '/v1/chat')).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET' })); | ||
| }); | ||
|
|
||
| it('keeps the sanitized URL with `traceLifecycle: "static"`', () => { | ||
| expect(trackStreamedFetch('static', 'https://api.example.com/v1/chat?stream=1')).toHaveBeenCalledWith( | ||
| expect.objectContaining({ name: 'GET https://api.example.com/v1/chat' }), | ||
| ); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.