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 @@ -193,14 +193,12 @@ it('cacheClient: true - two consecutive invocations get different isolation scop
});

it('cacheClient: true - streaming response works with shared client', async ({ signal }) => {
// A streamed request produces two span envelopes — the DO's own `GET /streaming` and the
// outer worker's `GET /cache/streaming` — and their arrival order is not guaranteed. Accept
// either, since the point is that spans still reach the transport at all.
// A streamed request produces two span envelopes — the DO's own and the outer worker's — and
// their arrival order is not guaranteed. Neither resolves a route, so with span streaming both
// are named after the request method; the point is that spans still reach the transport at all.
const streamingSpanExpectation = (envelope: Envelope) => {
const payload = envelope[1]?.[0]?.[1] as { items?: Array<{ name?: string }> };
expect(payload.items?.map(span => span.name)).toEqual(
expect.arrayContaining([expect.stringMatching(/^GET \/(cache\/)?streaming$/)]),
);
expect(payload.items?.map(span => span.name)).toEqual(expect.arrayContaining(['GET']));
};

const runner = createRunner(__dirname).start(signal);
Expand Down Expand Up @@ -314,9 +312,10 @@ it('cacheClient: true - burst DO RPC spans share the worker request trace', asyn
const started = runner
.expect((envelope: Envelope) => {
const payload = envelope[1]?.[0]?.[1] as SpanV2Payload;
const root = payload.items?.find(span => span.name === 'GET /burst');
const root = payload.items?.find(span => span.attributes?.['sentry.op']?.value === 'http.server');
expect(root).toBeDefined();
expect(root?.attributes?.['sentry.op']?.value).toBe('http.server');
// `/burst` resolves no route, so with span streaming the name is the request method.
expect(root?.name).toBe('GET');
workerTraceId = root?.trace_id;
})
.unordered()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ const SEGMENT_SPAN = {
},
'sentry.segment.name': {
type: 'string',
value: 'GET /test-sentry-span',
value: 'GET',
},
'sentry.segment.name.source': {
type: 'string',
Expand Down Expand Up @@ -154,7 +154,7 @@ const SEGMENT_SPAN = {
},
end_timestamp: expect.any(Number),
is_segment: true,
name: 'GET /test-sentry-span',
name: 'GET',
span_id: expect.stringMatching(/^[\da-f]{16}$/),
start_timestamp: expect.any(Number),
status: 'ok',
Expand Down Expand Up @@ -200,7 +200,7 @@ test('Sends streamed spans (http.server and manual with Sentry.startSpan)', asyn
},
'sentry.segment.name': {
type: 'string',
value: 'GET /test-sentry-span',
value: 'GET',
},
},
end_timestamp: expect.any(Number),
Expand Down Expand Up @@ -230,10 +230,10 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL })
const httpServerSpan = spans.find(span => getSpanOp(span) === 'http.server');
expect(httpServerSpan).toEqual({
...SEGMENT_SPAN,
name: 'GET /test-interop',
name: 'GET',
attributes: {
...SEGMENT_SPAN.attributes,
'sentry.segment.name': { type: 'string', value: 'GET /test-interop' },
'sentry.segment.name': { type: 'string', value: 'GET' },
'url.full': { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-interop$/) },
'url.path': { type: 'string', value: '/test-interop' },
},
Expand Down Expand Up @@ -272,7 +272,7 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL })
},
'sentry.segment.name': {
type: 'string',
value: 'GET /test-interop',
value: 'GET',
},
},
end_timestamp: expect.any(Number),
Expand Down Expand Up @@ -313,7 +313,7 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL })
},
'sentry.segment.name': {
type: 'string',
value: 'GET /test-interop',
value: 'GET',
},
'sentry.deno_tracer': {
type: 'boolean',
Expand Down
13 changes: 11 additions & 2 deletions packages/astro/src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
getRootSpan,
getUrlFragment,
getUrlQuery,
hasSpanStreamingEnabled,
HTTP_SPAN_NAME_FALLBACK,
objectify,
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD,
spanToJSON,
Expand Down Expand Up @@ -235,9 +237,16 @@ async function instrumentRequestStartHttpServerSpan(
attributes[URL_QUERY] = filterCollectedUrlQuery(getUrlQuery(ctx.url.search));
attributes[URL_FRAGMENT] = getUrlFragment(ctx.url.hash);

const name = `${method} ${parametrizedRoute || ctx.url.pathname}`;
const transactionName = `${method} ${parametrizedRoute || ctx.url.pathname}`;

isolationScope.setTransactionName(name);
// The scope's transaction name is what error events are grouped by, so it keeps the URL path.
isolationScope.setTransactionName(transactionName);

// With span streaming, span names have to be low cardinality, so we can't fall back to the URL path.
const name =
parametrizedRoute || !hasSpanStreamingEnabled(client)
? transactionName
: method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK;

const res = await startSpan(
{
Expand Down
65 changes: 65 additions & 0 deletions packages/astro/test/server/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Client, Span } from '@sentry/core';

import * as SentryCore from '@sentry/core';
import * as SentryNode from '@sentry/node';
import type { APIContext } from 'astro';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { handleRequest, interpolateRouteFromUrlAndParams } from '../../src/server/middleware';

Expand Down Expand Up @@ -133,6 +134,70 @@ describe('sentryMiddleware', () => {
expect(resultFromNext).toStrictEqual(nextResult);
});

describe('with span streaming enabled', () => {
// A full `APIContext` is much larger than these tests need, so build a partial one behind a typed
// helper rather than suppressing the type error at each call site.
function mockApiContext(override: Record<string, unknown>): APIContext {
return { ...DYNAMIC_REQUEST_CONTEXT, ...override } as unknown as APIContext;
}

beforeEach(() => {
vi.spyOn(SentryNode, 'getClient').mockImplementation(
() =>
({
getOptions: () => ({ traceLifecycle: 'stream' }),
getDataCollectionOptions: () => ({
userInfo: false,
cookies: true,
httpHeaders: { request: true, response: true },
httpBodies: [],
urlQueryParams: true,
graphQL: { document: true, variables: true },
genAI: { inputs: true, outputs: true },
databaseQueryData: true,
stackFrameVariables: true,
frameContextLines: 5,
}),
}) as unknown as Client,
);
});

it('names an unparameterized span after the request method', async () => {
const middleware = handleRequest();
const ctx = mockApiContext({
request: { method: 'GET', url: '/a%xx', headers: new Headers() },
url: { pathname: 'a%xx', href: 'http://localhost:1234/a%xx' },
params: {},
});

await middleware(
ctx,
vi.fn(() => nextResult),
);

expect(startSpanSpy).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET' }), expect.any(Function));
});

it('keeps the parameterized route as the span name', async () => {
const middleware = handleRequest();
const ctx = mockApiContext({
request: { method: 'GET', url: '/users/123/details', headers: new Headers() },
params: { id: '123' },
url: new URL('https://myDomain.io/users/123/details'),
});

await middleware(
ctx,
vi.fn(() => nextResult),
);

expect(startSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({ name: 'GET /users/[id]/details' }),
expect.any(Function),
);
});
});
Comment thread
chargome marked this conversation as resolved.

it("sets source route if the url couldn't be decoded correctly", async () => {
const middleware = handleRequest();
const ctx = {
Expand Down
8 changes: 7 additions & 1 deletion packages/bun/src/integrations/bunserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
getClient,
getUrlFragment,
getUrlQuery,
hasSpanStreamingEnabled,
httpHeadersToSpanAttributes,
HTTP_SPAN_NAME_FALLBACK,
isURLObjectRelative,
parseStringToURLObject,
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD,
Expand Down Expand Up @@ -246,7 +248,11 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
{
attributes,
op: 'http.server',
name: `${request.method} ${routeName}`,
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL path.
name:
attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !client || !hasSpanStreamingEnabled(client)
? `${request.method} ${routeName}`
: request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK,
Comment thread
chargome marked this conversation as resolved.
},
async span => {
try {
Expand Down
8 changes: 4 additions & 4 deletions packages/bun/test/integrations/bunserver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe('Bun Serve Integration', () => {
'http.request.header.user_agent': expect.stringContaining('Bun'),
}),
op: 'http.server',
name: 'GET /users',
name: 'GET',
},
expect.any(Function),
);
Expand Down Expand Up @@ -119,7 +119,7 @@ describe('Bun Serve Integration', () => {
'http.request.header.user_agent': expect.stringContaining('Bun'),
}),
op: 'http.server',
name: 'POST /',
name: 'POST',
},
expect.any(Function),
);
Expand Down Expand Up @@ -148,7 +148,7 @@ describe('Bun Serve Integration', () => {
'http.request.method': 'QUERY',
}),
op: 'http.server',
name: 'QUERY /search',
name: 'QUERY',
}),
expect.any(Function),
);
Expand Down Expand Up @@ -243,7 +243,7 @@ describe('Bun Serve Integration', () => {
'http.request.header.sentry_trace': expect.any(String),
}),
op: 'http.server',
name: 'POST /api/test',
name: 'POST',
}),
expect.any(Function),
);
Expand Down
16 changes: 14 additions & 2 deletions packages/cloudflare/src/wrapRequestHandlerWithInit.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import type { CfProperties, IncomingRequestCfProperties } from '@cloudflare/workers-types';
import { NETWORK_PROTOCOL_NAME, NETWORK_PROTOCOL_VERSION } from '@sentry/conventions/attributes';
import {
NETWORK_PROTOCOL_NAME,
NETWORK_PROTOCOL_VERSION,
SENTRY_SEGMENT_NAME_SOURCE,
} from '@sentry/conventions/attributes';
import {
captureException,
continueTrace,
getHttpSpanDetailsFromUrlObject,
hasSpanStreamingEnabled,
httpHeadersToSpanAttributes,
HTTP_SPAN_NAME_FALLBACK,
parseStringToURLObject,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
setHttpStatus,
Expand Down Expand Up @@ -76,14 +82,20 @@ export function wrapRequestHandlerWithInit(
isolationScope.setClient(client);

const urlObject = parseStringToURLObject(request.url);
const [name, attributes] = getHttpSpanDetailsFromUrlObject(
const [rawName, attributes] = getHttpSpanDetailsFromUrlObject(
urlObject,
'server',
'auto.http.cloudflare',
request,
undefined,
client,
);
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
// A `route` source means the name already is (e.g. the `/` path), so it is kept as-is.
const name =
attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !client || !hasSpanStreamingEnabled(client)
? rawName
: request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK;

const contentLength = request.headers.get('content-length');
if (contentLength) {
Expand Down
30 changes: 30 additions & 0 deletions packages/cloudflare/test/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,36 @@ describe('withSentry', () => {
vi.clearAllMocks();
});

describe('with span streaming enabled', () => {
async function segmentSpanNameFor(url: string): Promise<string | undefined> {
let spanName: string | undefined;

await wrapRequestHandler(
{
options: { ...MOCK_OPTIONS, traceLifecycle: 'stream', tracesSampleRate: 1 },
request: new Request(url),
context: createMockExecutionContext(),
},
() => {
// Read the name while the request is in flight: the gate applies at span start.
const activeSpan = SentryCore.getActiveSpan();
spanName = activeSpan ? SentryCore.spanToJSON(SentryCore.getRootSpan(activeSpan)).name : undefined;
return new Response('test');
},
);

return spanName;
}

test('names a span without a resolvable route after the request method', async () => {
expect(await segmentSpanNameFor('https://example.com/users/42')).toBe('GET');
});

test('keeps the root path, which is already low cardinality', async () => {
expect(await segmentSpanNameFor('https://example.com/')).toBe('GET /');
});
});

test('passes through the response from the handler', async () => {
const response = new Response('test');
const result = await wrapRequestHandler(
Expand Down
17 changes: 15 additions & 2 deletions packages/deno/src/wrap-deno-request-handler.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { CLIENT_ADDRESS, CLIENT_PORT, NETWORK_PROTOCOL_NAME } from '@sentry/conventions/attributes';
import {
CLIENT_ADDRESS,
CLIENT_PORT,
NETWORK_PROTOCOL_NAME,
SENTRY_SEGMENT_NAME_SOURCE,
} from '@sentry/conventions/attributes';
import type { Integration, MaxRequestBodySize } from '@sentry/core';
import {
captureBodyFromWinterCGRequest,
captureException,
continueTrace,
getClient,
getHttpSpanDetailsFromUrlObject,
hasSpanStreamingEnabled,
httpHeadersToSpanAttributes,
HTTP_SPAN_NAME_FALLBACK,
parseStringToURLObject,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
setHttpStatus,
Expand Down Expand Up @@ -60,14 +67,20 @@ export const wrapDenoRequestHandler = <Addr extends Deno.Addr = Deno.Addr>(
}

const urlObject = parseStringToURLObject(request.url);
const [name, attributes] = getHttpSpanDetailsFromUrlObject(
const [rawName, attributes] = getHttpSpanDetailsFromUrlObject(
urlObject,
'server',
'auto.http.deno',
request,
undefined,
client,
);
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
// A `route` source means the name already is (e.g. the `/` path), so it is kept as-is.
const name =
attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !hasSpanStreamingEnabled(client)
? rawName
: request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK;

const contentLength = request.headers.get('content-length');
assignIfSet(attributes, 'http.request.body.size', contentLength && parseInt(contentLength, 10));
Expand Down
Loading
Loading