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
@@ -0,0 +1,8 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
transport: loggingTransport,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
transport: loggingTransport,
integrations: [Sentry.nitroIntegration({ serverTiming: false, storage: false })],
});
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,48 @@ describe.runIf(supports({ min: 20 }))('nitro integrations', () => {
await runner.completed();
});
});

createEsmTests(__dirname, 'scenario.mjs', 'instrument-no-tracing.mjs', (createRunner, test) => {
test('sets Server-Timing headers without tracing enabled', async () => {
const runner = createRunner().start();

const port = await vi.waitFor(() => runner.getPort() ?? Promise.reject(new Error('port not ready')), {
timeout: 30_000,
});
const response = await fetch(`http://localhost:${port}/hello/world`);
expect(await response.text()).toBe('Hello, world!');

const serverTiming = response.headers.get('server-timing');
expect(serverTiming).toMatch(/sentry-trace;desc="[a-f\d]{32}-[a-f\d]{16}"/);
expect(serverTiming).toContain('baggage;desc="');
});
});

createEsmTests(__dirname, 'scenario.mjs', 'instrument-opt-out.mjs', (createRunner, test) => {
test('skips Server-Timing headers and cache spans when opted out', async () => {
const runner = createRunner()
.expect({
span: container => {
const segment = container.items.find(span => span.is_segment);
const cacheSpans = container.items.filter(
span => span.attributes[SENTRY_ORIGIN]?.value === 'auto.cache.nitro',
);

expect(segment?.name).toBe('GET /cache');
expect(cacheSpans).toEqual([]);
},
})
.start();

const port = await vi.waitFor(() => runner.getPort() ?? Promise.reject(new Error('port not ready')), {
timeout: 30_000,
});
const response = await fetch(`http://localhost:${port}/cache`);
expect(await response.text()).toBe('value');

await runner.completed();

expect(response.headers.get('server-timing')).toBeNull();
});
});
});
1 change: 0 additions & 1 deletion packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ export {
mongooseIntegration,
mysql2Integration,
nitroIntegration,
nitroServerTimingIntegration,
mysqlIntegration,
nativeNodeFetchIntegration,
NodeClient,
Expand Down
1 change: 0 additions & 1 deletion packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ export {
mysqlIntegration,
mysql2Integration,
nitroIntegration,
nitroServerTimingIntegration,
redisIntegration,
tediousIntegration,
pinoIntegration,
Expand Down
1 change: 0 additions & 1 deletion packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@ export {
mysqlIntegration,
mysql2Integration,
nitroIntegration,
nitroServerTimingIntegration,
redisIntegration,
tediousIntegration,
postgresIntegration,
Expand Down
1 change: 0 additions & 1 deletion packages/deno/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ export {
mysqlIntegration,
mysql2Integration,
nitroIntegration,
nitroServerTimingIntegration,
mistralAIIntegration,
openAIIntegration,
groqIntegration,
Expand Down
1 change: 0 additions & 1 deletion packages/elysia/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ export {
mysqlIntegration,
mysql2Integration,
nitroIntegration,
nitroServerTimingIntegration,
redisIntegration,
tediousIntegration,
postgresIntegration,
Expand Down
1 change: 0 additions & 1 deletion packages/google-cloud-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ export {
mysqlIntegration,
mysql2Integration,
nitroIntegration,
nitroServerTimingIntegration,
redisIntegration,
tediousIntegration,
pinoIntegration,
Expand Down
6 changes: 2 additions & 4 deletions packages/nitro/src/sdk.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Integration } from '@sentry/core';
import { applySdkMetadata } from '@sentry/core';
import type { NodeClient, NodeOptions } from '@sentry/node';
import { getDefaultIntegrations as getDefaultNodeIntegrations, init as nodeInit, nitroIntegration } from '@sentry/node';
import { getDefaultIntegrations as getDefaultNodeIntegrations, init as nodeInit } from '@sentry/node';

/**
* Initializes the Nitro SDK
Expand All @@ -28,7 +28,5 @@ export function init(options: NodeOptions | undefined = {}): NodeClient | undefi
* @returns The default integrations for the Nitro SDK.
*/
export function getDefaultIntegrations(options: NodeOptions): Integration[] | undefined {
// `nitroIntegration` is also part of the Node tracing integrations, but add it explicitly so it is
// always present for the Nitro SDK regardless of that gating (deduped by name when both are added).
return [...getDefaultNodeIntegrations(options), nitroIntegration()];
return [...getDefaultNodeIntegrations(options)];
}
1 change: 0 additions & 1 deletion packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ export {
mistralAIIntegration,
mysql2Integration,
nitroIntegration,
nitroServerTimingIntegration,
openAIIntegration,
togetherAIIntegration,
typesafeIntegration,
Expand Down
1 change: 0 additions & 1 deletion packages/remix/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ export {
mongooseIntegration,
mysql2Integration,
nitroIntegration,
nitroServerTimingIntegration,
mysqlIntegration,
nativeNodeFetchIntegration,
NodeClient,
Expand Down
3 changes: 2 additions & 1 deletion packages/server-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export type { ExpressIntegrationOptions } from './integrations/express/types';
export { expressErrorHandler, setupExpressErrorHandler } from './integrations/express/error-handler';
/* oxlint-enable typescript/no-deprecated */
export { firebaseIntegration } from './integrations/firebase';
export { nitroIntegration, nitroServerTimingIntegration } from './integrations/nitro';
export { nitroIntegration } from './integrations/nitro';
export type { NitroIntegrationOptions } from './integrations/nitro';

export { getTracingIntegrations, getErrorIntegrations } from './integrations';
7 changes: 3 additions & 4 deletions packages/server-utils/src/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { neonIntegration } from './neon';
import { firebaseIntegration } from './firebase';
import { expressIntegration } from './express';
import { fastifyIntegration } from './fastify';
import { nitroIntegration, nitroServerTimingIntegration } from './nitro';
import { nitroIntegration } from './nitro';
import { hapiIntegration } from './hapi';
import { honoIntegration } from './hono';
import { koaIntegration } from './koa';
Expand Down Expand Up @@ -75,13 +75,12 @@ export function getTracingIntegrations(): Integration[] {
neonIntegration(),
firebaseIntegration(),
mcpServerIntegration(),
nitroIntegration(),
];
}

/**
* These are default integrations that are registered regardless of whether tracing is enabled -
* either because they cover error capture in addition to tracing, or because they only handle trace
* either because they cover error capture in addition to tracing, or because they handle trace
* propagation (which must work in tracing-without-performance mode too).
*/
export function getErrorIntegrations(): Integration[] {
Expand All @@ -91,6 +90,6 @@ export function getErrorIntegrations(): Integration[] {
hapiIntegration(),
honoIntegration(),
koaIntegration(),
nitroServerTimingIntegration(),
nitroIntegration(),
];
}
63 changes: 34 additions & 29 deletions packages/server-utils/src/integrations/nitro/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,45 +6,50 @@ import { captureServerTimingHeaders } from './setServerTimingHeaders';

const INTEGRATION_NAME = 'Nitro' as const;

const _nitroIntegration = (() => {
export interface NitroIntegrationOptions {
/**
* Set `Server-Timing` response headers carrying the current `sentry-trace`/`baggage`, so the browser
* SDK can connect a pageload trace to the backend trace.
*
* @default true
*/
serverTiming?: boolean;

/**
* Create cache spans for unstorage operations.
*
* @default true
*/
storage?: boolean;
}

const _nitroIntegration = ((options: NitroIntegrationOptions = {}) => {
const { serverTiming = true, storage = true } = options;

return {
name: INTEGRATION_NAME,
setupOnce() {
captureTracingEvents();
captureStorageEvents();
},
};
}) satisfies IntegrationFn;

/**
* Instruments a Nitro server via its `node:diagnostics_channel` tracing channels: HTTP server and
* middleware spans (h3/srvx) and cache spans (unstorage). It subscribes to those channels, so it is
* inert unless a Nitro app (or a framework that enables the same tracing channels) is running.
*
* Only creates spans, so it is registered as a tracing integration (active only when tracing is
* enabled). Trace-propagation headers are handled separately by {@link nitroServerTimingIntegration},
* and error capture by the `@sentry/nitro` runtime plugin's Nitro `error` hook.
*/
export const nitroIntegration = defineIntegration(_nitroIntegration);

const SERVER_TIMING_INTEGRATION_NAME = 'NitroServerTiming' as const;
if (storage) {
captureStorageEvents();
}

const _nitroServerTimingIntegration = (() => {
return {
name: SERVER_TIMING_INTEGRATION_NAME,
setupOnce() {
captureServerTimingHeaders();
if (serverTiming) {
captureServerTimingHeaders();
}
},
};
}) satisfies IntegrationFn;

/**
* Sets `Server-Timing` response headers carrying the current `sentry-trace`/`baggage` on every
* response served through Nitro's h3 request channel, so the browser SDK can connect a pageload
* trace to the backend trace.
* Instruments a Nitro server via its `node:diagnostics_channel` tracing channels: HTTP server and
* middleware spans (h3/srvx), cache spans (unstorage) and `Server-Timing` trace-propagation headers.
* It subscribes to those channels, so it is inert unless a Nitro app (or a framework that enables the
* same tracing channels) is running.
*
* Registered as a default (non-tracing) integration because trace propagation must work even in
* tracing-without-performance mode. It only appends headers when the h3 request channel fires, so it
* is inert outside Nitro. Opt out by excluding it from `integrations`.
* Registered regardless of whether tracing is enabled, because trace propagation must work in
* tracing-without-performance mode too; without tracing, the spans it starts are non-recording.
* Error capture is handled by the `@sentry/nitro` runtime plugin's Nitro `error` hook.
*/
export const nitroServerTimingIntegration = defineIntegration(_nitroServerTimingIntegration);
export const nitroIntegration = defineIntegration(_nitroIntegration);
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ const globalWithServerTiming = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
* This is deliberately independent of span sampling: `getTraceData()` returns valid trace
* propagation data even in tracing-without-performance mode, and the browser SDK reads these headers
* (via the Performance API) to connect a pageload to the backend trace regardless of whether the
* server records spans. Span creation itself lives in the separate `nitroIntegration`, which only
* activates when tracing is enabled.
* server records spans.
*/
export function captureServerTimingHeaders(): void {
if (globalWithServerTiming.__SENTRY_NITRO_SERVER_TIMING_INSTRUMENTED__) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [
{ exportName: 'redisIntegration', modules: ['redis', '@redis/client', 'ioredis'] },
{ exportName: 'dataloaderIntegration', modules: ['dataloader'] },
{ exportName: 'nitroIntegration', modules: ['h3'] },
{ exportName: 'nitroServerTimingIntegration', modules: ['unstorage'] },
] as const satisfies ReadonlyArray<{ exportName: string; modules: readonly string[] }>;

/** Look up the subscriber export name for an instrumented package, if any. */
Expand Down
26 changes: 11 additions & 15 deletions packages/server-utils/src/orchestrion/config/nitro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,20 @@ import { registrationOnly } from './registration-only';

/**
* Nitro's instrumentation consumes tracing channels that h3, srvx and unstorage publish natively, so
* no channels are injected: transforming these entry files only registers the Nitro integrations at
* evaluation time, which is what installs them on a bundler-only SDK like `@sentry/cloudflare`. On
* `@sentry/node` (and deno/bun) the integrations are registered statically, so this is a no-op there.
* no channels are injected: transforming h3's entry file only registers `nitroIntegration` at
* evaluation time, which is what installs it on a bundler-only SDK like `@sentry/cloudflare`. On
* `@sentry/node` (and deno/bun) the integration is registered statically, so this is a no-op there.
*
* Two anchors because the registration snippet maps a single integration per module (see
* `channel-integration-definitions.ts`): `h3` — which loads whenever the server runs — registers the
* span instrumentation, and `unstorage` — a core Nitro storage dependency — registers the
* Server-Timing trace-propagation headers. Both are core Nitro dependencies evaluated at startup, so
* a real Nitro app triggers both. The anchor only triggers registration; each integration then
* subscribes to all of its own channels, independent of which module loaded it.
* Anchored on `h3` because every HTTP-serving Nitro app loads it. The anchor only triggers
* registration; the integration then subscribes to all of its channels (including unstorage's),
* independent of which module loaded it.
*
* The `-0` in the version range matters: h3 and unstorage ship the versions Nitro 3 uses as
* prereleases (h3 `2.0.1-rc.*`, unstorage `2.0.0-alpha.*`). The orchestrion matcher is the vendored
* `semifies` (used by both the runtime loader and the bundler plugins), where a range carrying a
* prerelease tag enables prerelease matching across patch tuples — so `>=2.0.0-0` matches
* `2.0.1-rc.*`. (Plain node-semver would not: it drops prereleases of a higher patch tuple.)
* The `-0` in the version range matters: h3 ships the version Nitro 3 uses as a prerelease
* (`2.0.1-rc.*`). The orchestrion matcher is the vendored `semifies` (used by both the runtime loader
* and the bundler plugins), where a range carrying a prerelease tag enables prerelease matching
* across patch tuples — so `>=2.0.0-0` matches `2.0.1-rc.*`. (Plain node-semver would not: it drops
* prereleases of a higher patch tuple.)
*/
export const nitroConfig = [
registrationOnly({ name: 'h3', versionRange: '>=2.0.0-0', filePath: 'dist/h3.mjs' }),
registrationOnly({ name: 'unstorage', versionRange: '>=2.0.0-0', filePath: 'dist/index.mjs' }),
] satisfies InstrumentationConfig[];
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { captureStorageEvents } from '../../../src/integrations/nitro/captureStorageEvents';
import { captureTracingEvents } from '../../../src/integrations/nitro/captureTracingEvents';
import { captureServerTimingHeaders } from '../../../src/integrations/nitro/setServerTimingHeaders';
import { nitroIntegration } from '../../../src/integrations/nitro';

vi.mock('../../../src/integrations/nitro/captureStorageEvents', () => ({ captureStorageEvents: vi.fn() }));
vi.mock('../../../src/integrations/nitro/captureTracingEvents', () => ({ captureTracingEvents: vi.fn() }));
vi.mock('../../../src/integrations/nitro/setServerTimingHeaders', () => ({ captureServerTimingHeaders: vi.fn() }));

describe('nitroIntegration', () => {
afterEach(() => {
vi.clearAllMocks();
});

it('enables all instrumentation by default', () => {
nitroIntegration().setupOnce!();

expect(captureTracingEvents).toHaveBeenCalledTimes(1);
expect(captureStorageEvents).toHaveBeenCalledTimes(1);
expect(captureServerTimingHeaders).toHaveBeenCalledTimes(1);
});

it('skips Server-Timing headers when `serverTiming` is disabled', () => {
nitroIntegration({ serverTiming: false }).setupOnce!();

expect(captureTracingEvents).toHaveBeenCalledTimes(1);
expect(captureStorageEvents).toHaveBeenCalledTimes(1);
expect(captureServerTimingHeaders).not.toHaveBeenCalled();
});

it('skips storage spans when `storage` is disabled', () => {
nitroIntegration({ storage: false }).setupOnce!();

expect(captureTracingEvents).toHaveBeenCalledTimes(1);
expect(captureStorageEvents).not.toHaveBeenCalled();
expect(captureServerTimingHeaders).toHaveBeenCalledTimes(1);
});
});
1 change: 0 additions & 1 deletion packages/solidstart/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ export {
mongooseIntegration,
mysql2Integration,
nitroIntegration,
nitroServerTimingIntegration,
mysqlIntegration,
nativeNodeFetchIntegration,
NodeClient,
Expand Down
1 change: 0 additions & 1 deletion packages/sveltekit/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ export {
mongooseIntegration,
mysql2Integration,
nitroIntegration,
nitroServerTimingIntegration,
mysqlIntegration,
nativeNodeFetchIntegration,
NodeClient,
Expand Down
Loading