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 @@ -18,6 +18,7 @@
"devDependencies": {
"@playwright/test": "~1.63.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@sentry/conventions": "0.27.0",
"@sentry/core": "latest || *",
"nitro": "^3.0.260522-beta",
"rolldown": "latest",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils';
import { SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import { HANDLER, HTTP_SERVER, MIDDLEWARE } from '@sentry/conventions/op';

function collectNestingSpans() {
return collectStreamedSpansUntilSegment('nitro-3', 'GET /api/test-nesting');
Expand All @@ -24,49 +26,56 @@ test('Span nesting: all spans share the same trace_id', async ({ request }) => {
}
});

test('Span nesting: h3 middleware spans are children of the srvx request span', async ({ request }) => {
test('Span nesting: the segment is the only http.server span', async ({ request }) => {
const spansPromise = collectNestingSpans();

await request.get('/api/test-nesting');

const spans = await spansPromise;

// The srvx request span nests below the http_server segment span of the request
const segmentSpan = spans.find(span => span.is_segment && span.name === 'GET /api/test-nesting');
const srvxSpan = spans.find(
span => span.attributes['sentry.origin']?.value === 'auto.http.nitro.srvx' && getSpanOp(span) === 'http.server',
);
expect(srvxSpan).toBeDefined();
expect(srvxSpan?.parent_span_id).toBe(segmentSpan!.span_id);
const httpServerSpans = spans.filter(span => getSpanOp(span) === HTTP_SERVER);
expect(httpServerSpans).toHaveLength(1);
expect(httpServerSpans[0]).toMatchObject({
is_segment: true,
attributes: expect.objectContaining({
[SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.http_server' },
}),
});
});

test('Span nesting: h3 middleware spans are children of the segment span', async ({ request }) => {
const spansPromise = collectNestingSpans();

await request.get('/api/test-nesting');

// All h3 middleware spans should be children of the srvx span
const spans = await spansPromise;

const segmentSpan = spans.find(span => span.is_segment && span.name === 'GET /api/test-nesting');
const h3MiddlewareSpans = spans.filter(
span => span.attributes['sentry.origin']?.value === 'auto.http.nitro.h3' && getSpanOp(span) === 'middleware',
span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.http.nitro.h3' && getSpanOp(span) === MIDDLEWARE,
);
expect(h3MiddlewareSpans.length).toBeGreaterThanOrEqual(1);

for (const span of h3MiddlewareSpans) {
expect(span.parent_span_id).toBe(srvxSpan!.span_id);
expect(span.parent_span_id).toBe(segmentSpan!.span_id);
}
});

test('Span nesting: h3 route handler span is a child of the srvx request span', async ({ request }) => {
test('Span nesting: h3 route handler span is a child of the segment span', async ({ request }) => {
const spansPromise = collectNestingSpans();

await request.get('/api/test-nesting');

const spans = await spansPromise;

const srvxSpan = spans.find(
span => span.attributes['sentry.origin']?.value === 'auto.http.nitro.srvx' && getSpanOp(span) === 'http.server',
);
expect(srvxSpan).toBeDefined();

const segmentSpan = spans.find(span => span.is_segment && span.name === 'GET /api/test-nesting');
const h3HandlerSpan = spans.find(
span => span.attributes['sentry.origin']?.value === 'auto.http.nitro.h3' && getSpanOp(span) === 'http.server',
span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.http.nitro.h3' && getSpanOp(span) === HANDLER,
);
expect(h3HandlerSpan).toBeDefined();
expect(h3HandlerSpan!.parent_span_id).toBe(srvxSpan!.span_id);
expect(h3HandlerSpan).toMatchObject({
name: 'GET /api/test-nesting',
parent_span_id: segmentSpan!.span_id,
});
});

test('Span nesting: manual startSpan calls inside route handler are children of the h3 route handler span', async ({
Expand All @@ -80,7 +89,7 @@ test('Span nesting: manual startSpan calls inside route handler are children of

// Find the h3 route handler span
const h3HandlerSpan = spans.find(
span => span.attributes['sentry.origin']?.value === 'auto.http.nitro.h3' && getSpanOp(span) === 'http.server',
span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.http.nitro.h3' && getSpanOp(span) === HANDLER,
);
expect(h3HandlerSpan).toBeDefined();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
import { SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import { HANDLER, HTTP_SERVER } from '@sentry/conventions/op';

test('Sends a segment span for a successful route', async ({ request }) => {
const spansPromise = collectStreamedSpans('nitro-3', spans =>
Expand All @@ -10,17 +12,19 @@ test('Sends a segment span for a successful route', async ({ request }) => {

const spans = await spansPromise;

// The node http server integration creates the segment span; srvx and h3 spans nest below it
// The node http server integration creates the segment span, and the only http.server span
const segmentSpan = spans.find(span => span.is_segment && span.name === 'GET /api/test-transaction');
expect(segmentSpan).toBeDefined();
expect(segmentSpan?.attributes['sentry.origin']?.value).toBe('auto.http.http_server');
expect(getSpanOp(segmentSpan!)).toBe('http.server');
expect(segmentSpan?.attributes[SENTRY_ORIGIN]?.value).toBe('auto.http.http_server');
expect(getSpanOp(segmentSpan!)).toBe(HTTP_SERVER);

expect(spans.filter(span => getSpanOp(span) === HTTP_SERVER)).toHaveLength(1);

// h3 creates a child span for the route handler
const h3Spans = spans.filter(
span => span.trace_id === segmentSpan?.trace_id && span.attributes['sentry.origin']?.value === 'auto.http.nitro.h3',
const h3HandlerSpan = spans.find(
span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.http.nitro.h3' && getSpanOp(span) === HANDLER,
);
expect(h3Spans.length).toBeGreaterThanOrEqual(1);
expect(h3HandlerSpan?.parent_span_id).toBe(segmentSpan?.span_id);
});

test('Sets correct HTTP status code on the segment span', async ({ request }) => {
Expand Down
31 changes: 16 additions & 15 deletions dev-packages/node-integration-tests/suites/tracing/nitro/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import {
HTTP_ROUTE,
SENTRY_OP,
SENTRY_ORIGIN,
URL_PATH_PARAMETER_KEY_BASE,
} from '@sentry/conventions/attributes';
import { CACHE_GET, CACHE_PUT, HTTP_SERVER } from '@sentry/conventions/op';
import { CACHE_GET, CACHE_PUT, HANDLER, HTTP_SERVER } from '@sentry/conventions/op';
import { afterAll, describe, expect, vi } from 'vitest';
import { supports } from '../../../utils';
import { cleanupChildProcesses, createEsmTests } from '../../../utils/runner';
Expand All @@ -20,39 +21,39 @@ describe.runIf(supports({ min: 20 }))('nitro integrations', () => {
});

createEsmTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
test('creates h3 and srvx spans and sets Server-Timing headers', async () => {
test('creates a single http.server span with a request handler span and sets Server-Timing headers', async () => {
let traceId: string | undefined;

const runner = createRunner()
.expect({
span: container => {
const segment = container.items.find(span => span.is_segment);
const srvxSpan = container.items.find(
span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.http.nitro.srvx',
);
const h3Span = container.items.find(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.http.nitro.h3');
const children = container.items.filter(span => !span.is_segment);
const handlerSpan = children.find(span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.http.nitro.h3');

traceId = segment?.trace_id;

expect(segment?.name).toBe('GET /hello/:name');
expect(segment?.attributes[HTTP_ROUTE]).toEqual({ type: 'string', value: '/hello/:name' });

// Bun publishes no `http.server.request.start`, so there the srvx span is the segment itself.
expect(srvxSpan).toMatchObject({
// On Node the runtime's http instrumentation creates the segment. Bun publishes no
// `http.server.request.start`, so there srvx creates it instead.
expect(segment).toMatchObject({
name: 'GET /hello/:name',
status: 'ok',
attributes: expect.objectContaining({
[SENTRY_OP]: { type: 'string', value: HTTP_SERVER },
[HTTP_ROUTE]: { type: 'string', value: '/hello/:name' },
[HTTP_RESPONSE_STATUS_CODE]: { type: 'integer', value: 200 },
[`${URL_PATH_PARAMETER_KEY_BASE}.name`]: { type: 'string', value: 'world' },
}),
});

expect(h3Span).toMatchObject({
expect(children.filter(span => span.attributes[SENTRY_OP]?.value === HTTP_SERVER)).toEqual([]);

expect(handlerSpan).toMatchObject({
name: 'GET /hello/:name',
parent_span_id: srvxSpan?.span_id,
parent_span_id: segment?.span_id,
status: 'ok',
attributes: expect.objectContaining({
[SENTRY_OP]: { type: 'string', value: HTTP_SERVER },
[HTTP_ROUTE]: { type: 'string', value: '/hello/:name' },
[SENTRY_OP]: { type: 'string', value: HANDLER },
}),
});
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
import { HTTP_SERVER, MIDDLEWARE } from '@sentry/conventions/op';
import { HANDLER, HTTP_SERVER, MIDDLEWARE } from '@sentry/conventions/op';
import {
isObjectLike,
getActiveSpan,
Expand All @@ -13,6 +13,7 @@ import {
parseStringToURLObject,
setHttpStatus,
type Span,
spanToJSON,
startInactiveSpan,
} from '@sentry/core';
import { bindTracingChannelToSpan, type TracingChannelPayloadWithSpan } from '../../tracing-channel';
Expand Down Expand Up @@ -60,6 +61,25 @@ function applyResponseStatus(span: Span, data: TracingChannelPayloadWithSpan<{ r
}
}

/**
* Whether the request already has an `http.server` root span, created by the runtime's own HTTP
* server instrumentation (Node `http`, `Bun.serve`, `Deno.serve`, Cloudflare's request wrapper).
*/
function hasHttpServerRootSpan(): boolean {
const activeSpan = getActiveSpan();
return !!activeSpan && spanToJSON(getRootSpan(activeSpan)).attributes[SENTRY_OP] === HTTP_SERVER;
}

/**
* The port the srvx server listens on. `options.port` is only the configured one, which is `0` for a
* random port.
*/
function getServerPort(server: SrvxRequestEvent['server']): number | undefined {
const url = server.url ? parseStringToURLObject(server.url) : undefined;
const port = url && 'port' in url ? Number(url.port) : undefined;
return port || server.options.port || undefined;
}

/**
* Extracts the parameterized route pattern from the h3 event context.
*/
Expand Down Expand Up @@ -88,6 +108,25 @@ function setupH3TracingChannels(): void {
bindTracingChannelToSpan(
diagnosticsChannel.tracingChannel<H3TracingRequestEvent>('h3.request'),
data => {
const method = data.event.req.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK;

// The route lives on the request's `http.server` root span, so the route handler only gets a
// span that times the handler itself instead of yet another nested `http.server` span.
if (data.type === 'route' && hasHttpServerRootSpan()) {
const matchedRoute = data.event.context?.matchedRoute?.route;
const span = startInactiveSpan({
name: matchedRoute ? `${method} ${matchedRoute}` : method,
attributes: {
[SENTRY_ORIGIN]: 'auto.http.nitro.h3',
[SENTRY_OP]: HANDLER,
},
});

setParameterizedRouteAttributes(span, data.event);

return span;
}

const parsedUrl = parseStringToURLObject(data.event.url.href);
const routePattern = getParameterizedRoute(data.event);

Expand All @@ -103,19 +142,17 @@ function setupH3TracingChannels(): void {
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
// Only applies to the http.server span; middleware spans keep their own naming.
const isUnparameterizedStreamedServerSpan =
data?.type !== 'middleware' &&
data.type !== 'middleware' &&
urlAttributes[SENTRY_SEGMENT_NAME_SOURCE] !== 'route' &&
!!client &&
hasSpanStreamingEnabled(client);

const span = startInactiveSpan({
name: isUnparameterizedStreamedServerSpan
? data.event.req.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK
: spanName,
name: isUnparameterizedStreamedServerSpan ? method : spanName,
attributes: {
...urlAttributes,
[SENTRY_ORIGIN]: 'auto.http.nitro.h3',
[SENTRY_OP]: data?.type === 'middleware' ? MIDDLEWARE : HTTP_SERVER,
[SENTRY_OP]: data.type === 'middleware' ? MIDDLEWARE : HTTP_SERVER,
},
});

Expand All @@ -127,8 +164,8 @@ function setupH3TracingChannels(): void {
beforeSpanEnd(span, data) {
applyResponseStatus(span, data);

// Update the root span (srvx transaction) with the parameterized route name.
// The srvx span is created before h3 resolves the route, so it initially has the raw URL.
// Update the root span with the parameterized route name. The root span is created before h3
// resolves the route, so it initially has the raw URL.
// Note: data.type is always 'middleware' here regardless of handler type, so we rely on
// getParameterizedRoute() to filter out catch-all routes instead.
const routePattern = getParameterizedRoute(data.event);
Expand All @@ -152,6 +189,11 @@ function setupSrvxTracingChannels(): void {
bindTracingChannelToSpan(
diagnosticsChannel.tracingChannel<SrvxRequestEvent>('srvx.request'),
data => {
// srvx only opens the request's `http.server` span when the runtime has not already created one.
if (hasHttpServerRootSpan()) {
return undefined;
}

const parsedUrl = data.request._url ? parseStringToURLObject(data.request._url.href) : undefined;
const client = getClient();
const [spanName, urlAttributes] = getHttpSpanDetailsFromUrlObject(
Expand Down Expand Up @@ -188,7 +230,7 @@ function setupSrvxTracingChannels(): void {
...headerAttributes,
[SENTRY_ORIGIN]: 'auto.http.nitro.srvx',
[SENTRY_OP]: data.middleware ? MIDDLEWARE : HTTP_SERVER,
'server.port': data.server.options.port,
'server.port': getServerPort(data.server),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hardcoded server.port attribute name

Low Severity

The srvx span still writes server.port as a string literal. @sentry/conventions/attributes already exports SERVER_PORT for this, and the Node HTTP server instrumentation uses that constant for the same field. Flagged because the review rules require conventions constants instead of hard-coded attribute names.

Fix in Cursor聽Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 715d8cd. Configure here.

},
// Use the same parent span as middleware to make them siblings
parentSpan: requestParentSpans.get(data.request) || undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export interface H3TracingRequestEvent {

/** Payload of the `srvx.request`/`srvx.middleware` tracing channels (`srvx/tracing`'s `RequestEvent`). */
export interface SrvxRequestEvent {
server: { options: { port?: number } };
server: { url?: string; options: { port?: number } };
request: {
_url?: { href: string; pathname: string };
method: string;
Expand Down
Loading