Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ test('Should trace outgoing fetch requests inside middleware and create breadcru
'sentry.origin': 'auto.http.node_fetch',
'server.address': 'localhost',
'server.port': 3030,
'url.domain': 'localhost',
'url.full': 'http://localhost:3030/',
'url.path': '/',
'url.scheme': 'http',
Expand Down

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: unless I'm missing something, can we still assert on the span name here?

Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,22 @@ test('Sends streamed spans for an errored route', async ({ baseURL }) => {

test('Outgoing fetch spans are streamed', async ({ baseURL }) => {
const fetchSpanPromise = waitForStreamedSpan('node-express-streaming', span => {
return getSpanOp(span) === 'http.client' && !span.is_segment && span.name.includes('localhost:3030/test-success');
// A streamed name keeps only the domain, which every outgoing span here shares, so select on
// `url.full` and assert the name below.
return (
getSpanOp(span) === 'http.client' &&
!span.is_segment &&
String(span.attributes['url.full']?.value ?? '').includes('localhost:3030/test-success')
);
});

await fetch(`${baseURL}/test-outgoing-fetch`);

const fetchSpan = await fetchSpanPromise;

expect(fetchSpan).toBeDefined();
expect(fetchSpan.name).toBe('GET localhost');
expect(fetchSpan.attributes['url.domain']?.value).toBe('localhost');
expect(fetchSpan.status).toBe('ok');
});

Expand All @@ -96,14 +104,21 @@ test.skip('Outgoing fetch spans include response headers when headersToSpanAttri
baseURL,
}) => {
const fetchSpanPromise = waitForStreamedSpan('node-express-streaming', span => {
return getSpanOp(span) === 'http.client' && !span.is_segment && span.name.includes('localhost:3030/test-success');
// A streamed name keeps only the domain, which every outgoing span here shares, so select on
// `url.full` and assert the name below.
return (
getSpanOp(span) === 'http.client' &&
!span.is_segment &&
String(span.attributes['url.full']?.value ?? '').includes('localhost:3030/test-success')
);
});

await fetch(`${baseURL}/test-outgoing-fetch`);

const fetchSpan = await fetchSpanPromise;

expect(fetchSpan).toBeDefined();
expect(fetchSpan.name).toBe('GET localhost');
expect(fetchSpan.attributes['http.response.header.content-length']).toBeDefined();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ describe('http.client span with streaming enabled', () => {
);

expect(httpClientSpan).toBeDefined();
expect(httpClientSpan?.name).toMatch(/^GET .*\/external$/);
// The URL path is high cardinality, so a streamed span name keeps only the domain.
expect(httpClientSpan?.name).toBe('GET localhost');
expect(httpClientSpan?.attributes['url.domain']?.value).toBe('localhost');
},
})
.start();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe('streamed outgoing fetch spans', () => {

createCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
test('infers sentry.op for streamed outgoing fetch spans', async () => {
expect.assertions(2);
expect.assertions(4);

const [SERVER_URL, closeTestServer] = await createTestServer()
.get('/api/v0', () => {
Expand All @@ -27,6 +27,9 @@ describe('streamed outgoing fetch spans', () => {
);

expect(httpClientSpan).toBeDefined();
// The URL path is high cardinality, so a streamed name keeps only the domain.
expect(httpClientSpan?.name).toBe('GET localhost');
expect(httpClientSpan?.attributes['url.domain']?.value).toBe('localhost');
},
})
.start()
Expand Down
25 changes: 17 additions & 8 deletions packages/core/src/integrations/http/get-outgoing-span-data.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { Span, SpanAttributes } from '../../types/span';
import { getClient } from '../../currentScopes';
import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled';
import { HTTP_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames';
import { filterCollectedUrl } from '../../utils/data-collection/filterCollectedUrl';
import { getContentLengthFromHeaders } from '../../utils/request';
import { getHttpSpanDetailsFromUrlObject, parseStringToURLObject } from '../../utils/url';
import { getHttpSpanDetailsFromUrlObject, isURLObjectRelative, parseStringToURLObject } from '../../utils/url';
import type { HttpClientRequest, HttpIncomingMessage } from './types';
import { getRequestUrlFromClientRequest } from './get-request-url';
import type { StartSpanOptions } from '../../types/startSpanOptions';
Expand Down Expand Up @@ -30,17 +33,23 @@ import { HTTP_CLIENT } from '@sentry/conventions/op';
*/
export function getOutgoingRequestSpanData(request: HttpClientRequest): StartSpanOptions {
const url = getRequestUrlFromClientRequest(request);
const [name, attributes] = getHttpSpanDetailsFromUrlObject(
parseStringToURLObject(url),
'client',
'auto.http.client',
request,
);
const urlObject = parseStringToURLObject(url);
const [name, attributes] = getHttpSpanDetailsFromUrlObject(urlObject, 'client', 'auto.http.client', request);

const userAgent = request.getHeader('user-agent');

// With span streaming, span names have to be low cardinality, so only the domain is kept. Outgoing
// requests have no route to fall back on, and a URL stays relative only when the request carried no
// host to build one from — server runtimes have no page origin to resolve that against, unlike
// browsers — so such a request is named after the method alone.
const client = getClient();
const method = request.method?.toUpperCase();
const domain = urlObject && !isURLObjectRelative(urlObject) ? urlObject.hostname : undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to #23682 (comment), I guess server-side, we can't really get a doman/hostname for relative URLs 🤔 So omitting it for relative URLs is probably fine... wdyt? logaf-lower-than-in-browser tbh 😅

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, added a comment!

const streamedName = method ? (domain ? `${method} ${domain}` : method) : HTTP_SPAN_NAME_FALLBACK;
const spanName = !!client && hasSpanStreamingEnabled(client) ? streamedName : name;

return {
name,
name: spanName,
attributes: {
[SENTRY_OP]: HTTP_CLIENT,
[SENTRY_KIND]: 'client',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Client } from '../../../../src/client';
import * as currentScopes from '../../../../src/currentScopes';
import {
getOutgoingRequestSpanData,
setIncomingResponseSpanData,
Expand All @@ -14,6 +16,7 @@ import {
NETWORK_TRANSPORT,
SERVER_ADDRESS,
SERVER_PORT,
URL_DOMAIN,
URL_FULL,
URL_PATH,
} from '@sentry/conventions/attributes';
Expand Down Expand Up @@ -76,6 +79,46 @@ describe('getOutgoingRequestSpanData', () => {
expect(result.name).toMatch(/^POST /);
});

describe('with span streaming enabled', () => {
afterEach(() => {
vi.restoreAllMocks();
});

function mockStreamingClient(): void {
vi.spyOn(currentScopes, 'getClient').mockReturnValue({
getOptions: () => ({ traceLifecycle: 'stream' }),
getDataCollectionOptions: () => ({ urlQueryParams: true }),
} as unknown as Client);
}

it('drops the URL path but keeps the domain', () => {
mockStreamingClient();
const result = getOutgoingRequestSpanData(makeMockRequest({ method: 'post' }));
expect(result.name).toBe('POST example.com');
});

it('falls back to `HTTP` when the request has no method', () => {
mockStreamingClient();
const result = getOutgoingRequestSpanData(makeMockRequest({ method: undefined }));
expect(result.name).toBe('HTTP');
});

it('still records the URL on `url.full`', () => {
mockStreamingClient();
const result = getOutgoingRequestSpanData(makeMockRequest());
expect(result.attributes![URL_FULL]).toBe('http://example.com/api/test');
});

// A request with no host leaves the URL relative, and a server runtime has no page origin to
// resolve it against, so there is no domain to name the span after.
it('falls back to the method alone when the request has no host', () => {
mockStreamingClient();
const result = getOutgoingRequestSpanData(makeMockRequest({ host: undefined }));
expect(result.name).toBe('GET');
expect(result.attributes![URL_DOMAIN]).toBeUndefined();
});
});

it('includes URL_FULL, HTTP_REQUEST_METHOD, URL_PATH, and server endpoint attributes', () => {
const result = getOutgoingRequestSpanData(makeMockRequest());
expect(result.attributes).toMatchObject({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type * as common from '@google-cloud/common';
import { HTTP_REQUEST_METHOD, SENTRY_OP, SERVER_ADDRESS, URL_FULL } from '@sentry/conventions/attributes';
import { HTTP_REQUEST_METHOD, SENTRY_OP, SERVER_ADDRESS, URL_DOMAIN, URL_FULL } from '@sentry/conventions/attributes';
import { HTTP_CLIENT } from '@sentry/conventions/op';
import type { Client, IntegrationFn } from '@sentry/core';
import {
defineIntegration,
fill,
getClient,
hasSpanStreamingEnabled,
isURLObjectRelative,
parseStringToURLObject,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand Down Expand Up @@ -56,16 +57,25 @@ export const googleCloudHttpIntegration = defineIntegration(_googleCloudHttpInte
function wrapRequestFunction(orig: RequestFunction): RequestFunction {
return function (this: common.Service, reqOpts: RequestOptions, callback: ResponseCallback): void {
const httpMethod = reqOpts.method || 'GET';
const span = SETUP_CLIENTS.has(getClient() as Client)
const client = getClient();
const serverAddress = getServerAddress(this.apiEndpoint);
// Span names must not contain a query string, and callers can pass any URI they want. With span
// streaming they have to be low cardinality on top of that, so the URI is dropped entirely and only
// the API endpoint is kept — `reqOpts.uri` is a path with no route to parameterize.
const streamedName = serverAddress ? `${httpMethod} ${serverAddress}` : httpMethod;
const span = SETUP_CLIENTS.has(client as Client)
? startInactiveSpan({
// Span names must not contain a query string, and callers can pass any URI they want.
name: `${httpMethod} ${stripUrlQueryAndFragment(reqOpts.uri)}`,
name:
!!client && hasSpanStreamingEnabled(client)
? streamedName
: `${httpMethod} ${stripUrlQueryAndFragment(reqOpts.uri)}`,
onlyIfParent: true,
attributes: {
[SENTRY_OP]: HTTP_CLIENT,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.serverless',
[HTTP_REQUEST_METHOD]: httpMethod,
[SERVER_ADDRESS]: getServerAddress(this.apiEndpoint),
[SERVER_ADDRESS]: serverAddress,
[URL_DOMAIN]: serverAddress,
Comment thread
chargome marked this conversation as resolved.
[URL_FULL]: filterCollectedUrl(reqOpts.uri),
},
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BigQuery } from '@google-cloud/bigquery';
import { HTTP_REQUEST_METHOD, SENTRY_OP, SERVER_ADDRESS, URL_FULL } from '@sentry/conventions/attributes';
import { HTTP_REQUEST_METHOD, SENTRY_OP, SERVER_ADDRESS, URL_DOMAIN, URL_FULL } from '@sentry/conventions/attributes';
import { HTTP_CLIENT } from '@sentry/conventions/op';
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
import { createTransport, NodeClient, setCurrentClient } from '@sentry/node';
Expand Down Expand Up @@ -34,8 +34,19 @@ describe('GoogleCloudHttp tracing', () => {
stackParser: () => [],
});

// `traceLifecycle` defaults to `'stream'`, so `mockClient` exercises the low-cardinality names.
const staticClient = new NodeClient({
tracesSampleRate: 1.0,
integrations: [],
traceLifecycle: 'static',
dsn: 'https://withAWSServices@domain/123',
transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})),
stackParser: () => [],
});

const integration = googleCloudHttpIntegration();
mockClient.addIntegration(integration);
staticClient.addIntegration(googleCloudHttpIntegration());

beforeEach(() => {
nock('https://www.googleapis.com')
Expand Down Expand Up @@ -78,32 +89,32 @@ describe('GoogleCloudHttp tracing', () => {
const resp = await bigquery.query('SELECT true AS foo');
expect(resp).toEqual([[{ foo: true }]]);
expect(mockStartInactiveSpan).toBeCalledWith({
name: 'POST /jobs',
name: 'POST bigquery.googleapis.com',
onlyIfParent: true,
attributes: {
[SENTRY_OP]: HTTP_CLIENT,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.serverless',
[HTTP_REQUEST_METHOD]: 'POST',
[SERVER_ADDRESS]: 'bigquery.googleapis.com',
[URL_DOMAIN]: 'bigquery.googleapis.com',
[URL_FULL]: '/jobs',
},
});
expect(mockStartInactiveSpan).toBeCalledWith({
name: expect.stringMatching(/^GET \/queries\/.+/),
name: 'GET bigquery.googleapis.com',
onlyIfParent: true,
attributes: {
[SENTRY_OP]: HTTP_CLIENT,
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.serverless',
[HTTP_REQUEST_METHOD]: 'GET',
[SERVER_ADDRESS]: 'bigquery.googleapis.com',
[URL_DOMAIN]: 'bigquery.googleapis.com',
[URL_FULL]: expect.stringMatching(/^\/queries\/.+/),
},
});
});

// Span names follow `METHOD scheme://host/path`, so a query string must never reach the name,
// whatever the caller passes as `uri`.
test('strips the query string from the span name', async () => {
async function requestDatasetsWithQueryString(): Promise<void> {
nock('https://bigquery.googleapis.com')
.get('/bigquery/v2/projects/project-id/datasets')
.query(true)
Expand All @@ -115,6 +126,21 @@ describe('GoogleCloudHttp tracing', () => {
(err: unknown) => (err ? reject(err) : resolve()),
);
});
}

// With span streaming the URI does not reach the name at all, so neither can the query string.
test('names the span after the method and the API endpoint', async () => {
await requestDatasetsWithQueryString();

expect(mockStartInactiveSpan).toBeCalledWith(expect.objectContaining({ name: 'GET bigquery.googleapis.com' }));
});

// Span names follow `METHOD scheme://host/path`, so a query string must never reach the name,
// whatever the caller passes as `uri`.
test('strips the query string from the span name with `traceLifecycle: "static"`', async () => {
setCurrentClient(staticClient);

await requestDatasetsWithQueryString();

expect(mockStartInactiveSpan).toBeCalledWith(expect.objectContaining({ name: 'GET /datasets' }));
const names = mockStartInactiveSpan.mock.calls.map(([args]) => (args as { name: string }).name);
Expand Down
16 changes: 14 additions & 2 deletions packages/node/src/integrations/http/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import type { ClientRequest, RequestOptions } from 'node:http';
import type { Span } from '@sentry/core';
import { URL_FULL } from '@sentry/conventions/attributes';
import { defineIntegration, getRequestUrlFromClientRequest, hasSpansEnabled, stripDataUrlContent } from '@sentry/core';
import {
defineIntegration,
getClient,
getRequestUrlFromClientRequest,
hasSpansEnabled,
hasSpanStreamingEnabled,
stripDataUrlContent,
} from '@sentry/core';
import type { NodeClient } from '../../sdk/client';
import type { HttpServerIntegrationOptions } from './httpServerIntegration';
import { httpServerIntegration } from './httpServerIntegration';
Expand Down Expand Up @@ -109,7 +116,12 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) =>
const url = getRequestUrlFromClientRequest(request);
if (url.startsWith('data:')) {
const sanitizedUrl = stripDataUrlContent(url);
span.updateName(`${request.method || 'GET'} ${sanitizedUrl}`);
// With span streaming the span already carries a low-cardinality name, so it must not be
// renamed back to something containing the URL.
const client = getClient();
if (!client || !hasSpanStreamingEnabled(client)) {
span.updateName(`${request.method || 'GET'} ${sanitizedUrl}`);
}
Comment on lines +119 to +124

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: should we also just set the request method in a new else block when a URL does not start with :data? Or is this already handled on a lower level?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already handled in getOutgoingRequestSpanData

span.setAttributes({
[URL_FULL]: sanitizedUrl,
});
Expand Down
Loading
Loading