Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
860feda
feat: init nitro SDK
logaretm Dec 31, 2025
5afc83e
feat: init nitro SDK
logaretm Dec 31, 2025
eef9e15
chore: added nitro dev deps
logaretm Dec 31, 2025
7333aff
feat: added h3 channel
logaretm Dec 31, 2025
417ccac
feat: added srvx channel and enhanced attr collection
logaretm Dec 31, 2025
77904b9
fix: ensure runtime plugins are present in the dist
logaretm Jan 27, 2026
2583713
feat: upgrade deps
logaretm Feb 5, 2026
277d393
chore: yarn dedup
logaretm Feb 5, 2026
9812db1
fix: tracing channel name
logaretm Feb 5, 2026
fe42720
ref: use only one global flag for channel installation
logaretm Feb 6, 2026
fb8e4d5
feat: handle http errors
logaretm Feb 9, 2026
c32e738
feat: added http status code handling
logaretm Feb 9, 2026
5d9e46d
feat: force enable tracing for the user
logaretm Feb 9, 2026
6f23c11
fix: tracing config may have changed
logaretm Feb 9, 2026
d0c6cb6
fix: correctly enable the config
logaretm Feb 9, 2026
b01484c
fix: configure externals correctly
logaretm Feb 9, 2026
0dc6e68
test: added e2e tests
logaretm Feb 9, 2026
fc2d763
test: test isolation scope
logaretm Feb 9, 2026
2e0a6ff
feat: added server timing headers
logaretm Feb 9, 2026
004200b
feat: use vite mode for better test coverage
logaretm Feb 9, 2026
7ae85a5
fix: update channel names and always end the spans
logaretm Feb 9, 2026
fb67daf
feat: ensure trace channel spans has correct origins
logaretm Feb 9, 2026
64d4f76
test: add middleware error test
logaretm Feb 9, 2026
6799854
feat: route parameterization
logaretm Feb 9, 2026
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
2 changes: 2 additions & 0 deletions dev-packages/e2e-tests/test-applications/nitro-3/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@sentry:registry=http://127.0.0.1:4873
@sentry-internal:registry=http://127.0.0.1:4873
11 changes: 11 additions & 0 deletions dev-packages/e2e-tests/test-applications/nitro-3/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Nitro E2E Test</title>
</head>
<body>
<h1>Nitro E2E Test App</h1>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as Sentry from '@sentry/nitro';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1,
});
29 changes: 29 additions & 0 deletions dev-packages/e2e-tests/test-applications/nitro-3/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "nitro-3",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"start": "PORT=3030 NODE_OPTIONS='--import ./instrument.mjs' node .output/server/index.mjs",
"clean": "npx rimraf node_modules pnpm-lock.yaml .output",
"test": "playwright test",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm test"
},
"dependencies": {
"@sentry/browser": "latest || *",
"@sentry/nitro": "latest || *"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@sentry/core": "latest || *",
"nitro": "https://pkg.pr.new/nitrojs/nitro@4001",
"rolldown": "latest",
"vite": "latest"
},
"volta": {
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const config = getPlaywrightConfig({
startCommand: `pnpm start`,
});

export default config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defineHandler } from 'nitro/h3';

export default defineHandler(() => {
return { status: 'ok' };
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defineHandler } from 'nitro/h3';

export default defineHandler(() => {
throw new Error('This is a test error');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { getDefaultIsolationScope, setTag } from '@sentry/core';
import { defineHandler } from 'nitro/h3';

export default defineHandler(() => {
setTag('my-isolated-tag', true);
// Check if the tag leaked into the default (global) isolation scope
setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']);

throw new Error('Isolation test error');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineHandler } from 'nitro/h3';

export default defineHandler(event => {
const id = event.req.url;
return { id };
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defineHandler } from 'nitro/h3';

export default defineHandler(() => {
return { status: 'ok', transaction: true };
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineHandler, getQuery, setResponseHeader } from 'nitro/h3';

export default defineHandler(event => {
setResponseHeader(event, 'x-sentry-test-middleware', 'executed');

const query = getQuery(event);
if (query['middleware-error'] === '1') {
throw new Error('Middleware error');
}
});
10 changes: 10 additions & 0 deletions dev-packages/e2e-tests/test-applications/nitro-3/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as Sentry from '@sentry/browser';

// Let's us test trace propagation
Sentry.init({
environment: 'qa',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
tunnel: 'http://localhost:3031/', // proxy server
integrations: [Sentry.browserTracingIntegration()],
tracesSampleRate: 1.0,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'nitro-3',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test('Sends an error event to Sentry', async ({ request }) => {
const errorEventPromise = waitForError('nitro-3', event => {
return !event.type && !!event.exception?.values?.some(v => v.value === 'This is a test error');
});

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

const errorEvent = await errorEventPromise;

// Nitro wraps thrown errors in an HTTPError with .cause, producing a chained exception
expect(errorEvent.exception?.values).toHaveLength(2);

// The innermost exception (values[0]) is the original thrown error
expect(errorEvent.exception?.values?.[0]?.type).toBe('Error');
expect(errorEvent.exception?.values?.[0]?.value).toBe('This is a test error');
expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(
expect.objectContaining({
handled: false,
type: 'auto.function.nitro',
}),
);

// The outermost exception (values[1]) is the HTTPError wrapper
expect(errorEvent.exception?.values?.[1]?.type).toBe('HTTPError');
expect(errorEvent.exception?.values?.[1]?.value).toBe('This is a test error');
});

test('Does not send 404 errors to Sentry', async ({ request }) => {
let errorReceived = false;

void waitForError('nitro-3', event => {
if (!event.type) {
errorReceived = true;
return true;
}
return false;
});

await request.get('/api/non-existent-route');

expect(errorReceived).toBe(false);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';

test('Isolation scope prevents tag leaking between requests', async ({ request }) => {
const transactionEventPromise = waitForTransaction('nitro-3', event => {
return event?.transaction === 'GET /api/test-isolation/:id';
});

const errorPromise = waitForError('nitro-3', event => {
return !event.type && !!event.exception?.values?.some(v => v.value === 'Isolation test error');
});

await request.get('/api/test-isolation/1').catch(() => {
// noop - route throws
});

const transactionEvent = await transactionEventPromise;
const error = await errorPromise;

// Assert that isolation scope works properly
expect(error.tags?.['my-isolated-tag']).toBe(true);
expect(error.tags?.['my-global-scope-isolated-tag']).not.toBeDefined();
expect(transactionEvent.tags?.['my-isolated-tag']).toBe(true);
expect(transactionEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';

test('Creates middleware spans for requests', async ({ request }) => {
const transactionEventPromise = waitForTransaction('nitro-3', event => {
return event?.transaction === 'GET /api/test-transaction';
});

const response = await request.get('/api/test-transaction');

expect(response.headers()['x-sentry-test-middleware']).toBe('executed');

const transactionEvent = await transactionEventPromise;

// h3 middleware spans have origin auto.http.nitro.h3 and op middleware.nitro
const h3MiddlewareSpans = transactionEvent.spans?.filter(
span => span.origin === 'auto.http.nitro.h3' && span.op === 'middleware.nitro',
);
expect(h3MiddlewareSpans?.length).toBeGreaterThanOrEqual(1);
});

test('Captures errors thrown in middleware with error status on span', async ({ request }) => {
const errorEventPromise = waitForError('nitro-3', event => {
return !event.type && !!event.exception?.values?.some(v => v.value === 'Middleware error');
});

const transactionEventPromise = waitForTransaction('nitro-3', event => {
return event?.transaction === 'GET /api/test-transaction' && event?.contexts?.trace?.status === 'internal_error';
});

await request.get('/api/test-transaction?middleware-error=1');

const errorEvent = await errorEventPromise;
expect(errorEvent.exception?.values?.some(v => v.value === 'Middleware error')).toBe(true);

const transactionEvent = await transactionEventPromise;

// The transaction span should have error status
expect(transactionEvent.contexts?.trace?.status).toBe('internal_error');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('Propagates server trace to client pageload via Server-Timing headers', async ({ page }) => {
const clientTxnPromise = waitForTransaction('nitro-3', event => {
return event?.contexts?.trace?.op === 'pageload';
});

await page.goto('/');

const clientTxn = await clientTxnPromise;

expect(clientTxn.contexts?.trace?.trace_id).toBeDefined();
expect(clientTxn.contexts?.trace?.trace_id).toMatch(/[a-f0-9]{32}/);
expect(clientTxn.contexts?.trace?.op).toBe('pageload');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('Sends a transaction event for a successful route', async ({ request }) => {
const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => {
return transactionEvent?.transaction === 'GET /api/test-transaction';
});

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

const transactionEvent = await transactionEventPromise;

expect(transactionEvent).toEqual(
expect.objectContaining({
transaction: 'GET /api/test-transaction',
type: 'transaction',
}),
);

// srvx.request creates a span for the request
const srvxSpans = transactionEvent.spans?.filter(span => span.origin === 'auto.http.nitro.srvx');
expect(srvxSpans?.length).toBeGreaterThanOrEqual(1);

// h3 creates a child span for the route handler
const h3Spans = transactionEvent.spans?.filter(span => span.origin === 'auto.http.nitro.h3');
expect(h3Spans?.length).toBeGreaterThanOrEqual(1);
});

test('Sets correct HTTP status code on transaction', async ({ request }) => {
const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => {
return transactionEvent?.transaction === 'GET /api/test-transaction';
});

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

const transactionEvent = await transactionEventPromise;

expect(transactionEvent.contexts?.trace?.data).toEqual(
expect.objectContaining({
'http.response.status_code': 200,
}),
);

expect(transactionEvent.contexts?.trace?.status).toBe('ok');
});

test('Uses parameterized route for transaction name', async ({ request }) => {
const transactionEventPromise = waitForTransaction('nitro-3', transactionEvent => {
return transactionEvent?.transaction === 'GET /api/test-param/:id';
});

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

const transactionEvent = await transactionEventPromise;

expect(transactionEvent).toEqual(
expect.objectContaining({
transaction: 'GET /api/test-param/:id',
transaction_info: expect.objectContaining({ source: 'route' }),
type: 'transaction',
}),
);

expect(transactionEvent.contexts?.trace?.data).toEqual(
expect.objectContaining({
'http.route': '/api/test-param/:id',
}),
);
});

test('Sets Server-Timing response headers for trace propagation', async ({ request }) => {
const response = await request.get('/api/test-transaction');
const headers = response.headers();

expect(headers['server-timing']).toBeDefined();
expect(headers['server-timing']).toContain('sentry-trace;desc="');
expect(headers['server-timing']).toContain('baggage;desc="');
});
14 changes: 14 additions & 0 deletions dev-packages/e2e-tests/test-applications/nitro-3/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"~/*": ["./*"]
}
},
"include": ["src/**/*.ts", "routes/**/*.ts", "vite.config.ts"]
}
15 changes: 15 additions & 0 deletions dev-packages/e2e-tests/test-applications/nitro-3/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { withSentryConfig } from '@sentry/nitro';
import { nitro } from 'nitro/vite';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [
nitro(
// FIXME: Nitro plugin has a type issue
// @ts-expect-error
withSentryConfig({
serverDir: './server',
}),
),
],
});
7 changes: 5 additions & 2 deletions packages/nitro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@
},
"dependencies": {
"@sentry/core": "10.38.0",
"@sentry/node": "10.38.0"
"@sentry/node": "10.38.0",
"otel-tracing-channel": "^0.2.0"
},
"devDependencies": {
"nitro": "^3.0.1-alpha.1"
"h3": "^2.0.1-rc.13",
"nitro": "^3.0.1-alpha.1",
"srvx": "^0.11.1"
},
"scripts": {
"build": "run-p build:transpile build:types",
Expand Down
5 changes: 2 additions & 3 deletions packages/nitro/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu
export default [
...makeNPMConfigVariants(
makeBaseNPMConfig({
entrypoints: ['src/index.ts'],
entrypoints: ['src/index.ts', 'src/runtime/plugins/server.ts'],
packageSpecificConfig: {
external: [/^nitro/],
external: [/^nitro/, 'otel-tracing-channel', /^h3/, /^srvx/],
},
}),
{ emitCjs: false },
),
];
Loading
Loading