Skip to content

Commit b188e61

Browse files
authored
feat(node): Add @sentry/node/preload hook (#12213)
This PR adds a new way to initialize `@sentry/node`, which allows to use the SDK with performance instrumentation even if you cannot (for whatever reason) call `Sentry.init()` at the very start of your app. ## CJS usage In CommonJS mode, you can run the SDK like this: ```bash node --require @sentry/node/preload ./app.js ``` ```js // app.js const express = require('express'); const Sentry = require('@sentry/node'); const dsn = await getSentryDsn(); Sentry.init({ dsn }); // express is instrumented even though we initialized Sentry late ``` ## ESM usage in ESM mode, you can run the SDK like this: ```bash node --import @sentry/node/preload ./app.mjs ``` ```js // app.mjs import express from 'express'; import * as Sentry from '@sentry/node'; const dsn = await getSentryDsn(); Sentry.init({ dsn }); // express is instrumented even though we initialized Sentry late ``` ## Configuration options This script will by default preload all opentelemetry instrumentation. You can choose to instrument only specific packages like this: ```bash SENTRY_PRELOAD_INTEGRATIONS="Http,Express,Graphql" --import @sentry/node/preload ./app.mjs ``` You can also enable debug logging for the script via `SENTRY_DEBUG=true`. ## Manually preloading It is also possible to manually call `preloadOpenTelemetry()` to achieve the same thing. For example, in a CJS app you could do the following thing if you want to initialize late but don't want to use `--require`: ```js // preload.js const Sentry = require('@sentry/node'); Sentry.preloadOpenTelemetry(); // app.js // call this first, before any other requires! require('./preload.js'); // Then, other stuff const express = require('express'); const Sentry = require('@sentry/node'); const dsn = await getSentryDsn(); Sentry.init({ dsn }); ```
1 parent 330750d commit b188e61

File tree

42 files changed

+1205
-362
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1205
-362
lines changed

.github/workflows/build.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,7 +1003,9 @@ jobs:
10031003
'create-remix-app-express-vite-dev',
10041004
'debug-id-sourcemaps',
10051005
'node-express-esm-loader',
1006+
'node-express-esm-preload',
10061007
'node-express-esm-without-loader',
1008+
'node-express-cjs-preload',
10071009
'nextjs-app-dir',
10081010
'nextjs-14',
10091011
'nextjs-15',

dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const NODE_EXPORTS_IGNORE = [
2020
'initWithoutDefaultIntegrations',
2121
'SentryContextManager',
2222
'validateOpenTelemetrySetup',
23+
'preloadOpenTelemetry',
2324
];
2425

2526
const nodeExports = Object.keys(SentryNode).filter(e => !NODE_EXPORTS_IGNORE.includes(e));
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "node-express-cjs-preload",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"start": "node --require @sentry/node/preload src/app.js",
7+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
8+
"test:build": "pnpm install",
9+
"test:assert": "playwright test"
10+
},
11+
"dependencies": {
12+
"@sentry/node": "latest || *",
13+
"@sentry/opentelemetry": "latest || *",
14+
"express": "4.19.2"
15+
},
16+
"devDependencies": {
17+
"@sentry-internal/event-proxy-server": "link:../../../event-proxy-server",
18+
"@playwright/test": "^1.27.1"
19+
},
20+
"volta": {
21+
"extends": "../../package.json",
22+
"node": "18.19.1"
23+
}
24+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { devices } from '@playwright/test';
2+
3+
// Fix urls not resolving to localhost on Node v17+
4+
// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575
5+
import { setDefaultResultOrder } from 'dns';
6+
setDefaultResultOrder('ipv4first');
7+
8+
const eventProxyPort = 3031;
9+
const expressPort = 3030;
10+
11+
/**
12+
* See https://playwright.dev/docs/test-configuration.
13+
*/
14+
const config = {
15+
testDir: './tests',
16+
/* Maximum time one test can run for. */
17+
timeout: 150_000,
18+
expect: {
19+
/**
20+
* Maximum time expect() should wait for the condition to be met.
21+
* For example in `await expect(locator).toHaveText();`
22+
*/
23+
timeout: 5000,
24+
},
25+
/* Run tests in files in parallel */
26+
fullyParallel: true,
27+
/* Fail the build on CI if you accidentally left test.only in the source code. */
28+
forbidOnly: !!process.env.CI,
29+
/* Retry on CI only */
30+
retries: 0,
31+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
32+
reporter: 'list',
33+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
34+
use: {
35+
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
36+
actionTimeout: 0,
37+
38+
/* Base URL to use in actions like `await page.goto('/')`. */
39+
baseURL: `http://localhost:${expressPort}`,
40+
},
41+
42+
/* Configure projects for major browsers */
43+
projects: [
44+
{
45+
name: 'chromium',
46+
use: {
47+
...devices['Desktop Chrome'],
48+
},
49+
},
50+
],
51+
52+
/* Run your local dev server before starting the tests */
53+
webServer: [
54+
{
55+
command: 'node start-event-proxy.mjs',
56+
port: eventProxyPort,
57+
stdout: 'pipe',
58+
stderr: 'pipe',
59+
},
60+
{
61+
command: 'pnpm start',
62+
port: expressPort,
63+
stdout: 'pipe',
64+
stderr: 'pipe',
65+
},
66+
],
67+
};
68+
69+
export default config;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
const Sentry = require('@sentry/node');
2+
const express = require('express');
3+
4+
const app = express();
5+
const port = 3030;
6+
7+
app.get('/test-success', function (req, res) {
8+
setTimeout(() => {
9+
res.status(200).end();
10+
}, 100);
11+
});
12+
13+
app.get('/test-transaction/:param', function (req, res) {
14+
setTimeout(() => {
15+
res.status(200).end();
16+
}, 100);
17+
});
18+
19+
app.get('/test-error', function (req, res) {
20+
Sentry.captureException(new Error('This is an error'));
21+
setTimeout(() => {
22+
Sentry.flush(2000).then(() => {
23+
res.status(200).end();
24+
});
25+
}, 100);
26+
});
27+
28+
Sentry.setupExpressErrorHandler(app);
29+
30+
app.use(function onError(err, req, res, next) {
31+
// The error id is attached to `res.sentry` to be returned
32+
// and optionally displayed to the user for support.
33+
res.statusCode = 500;
34+
res.end(res.sentry + '\n');
35+
});
36+
37+
async function run() {
38+
await new Promise(resolve => setTimeout(resolve, 1000));
39+
40+
Sentry.init({
41+
environment: 'qa', // dynamic sampling bias to keep transactions
42+
dsn: process.env.E2E_TEST_DSN,
43+
tunnel: `http://localhost:3031/`, // proxy server
44+
tracesSampleRate: 1,
45+
});
46+
47+
app.listen(port, () => {
48+
console.log(`Example app listening on port ${port}`);
49+
});
50+
}
51+
52+
run();
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/event-proxy-server';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'node-express-cjs-preload',
6+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError, waitForTransaction } from '@sentry-internal/event-proxy-server';
3+
4+
test('Should record exceptions captured inside handlers', async ({ request }) => {
5+
const errorEventPromise = waitForError('node-express-cjs-preload', errorEvent => {
6+
return !!errorEvent?.exception?.values?.[0]?.value?.includes('This is an error');
7+
});
8+
9+
await request.get('/test-error');
10+
11+
await expect(errorEventPromise).resolves.toBeDefined();
12+
});
13+
14+
test('Should record a transaction for a parameterless route', async ({ request }) => {
15+
const transactionEventPromise = waitForTransaction('node-express-cjs-preload', transactionEvent => {
16+
return transactionEvent?.transaction === 'GET /test-success';
17+
});
18+
19+
await request.get('/test-success');
20+
21+
await expect(transactionEventPromise).resolves.toBeDefined();
22+
});
23+
24+
test('Should record a transaction for route with parameters', async ({ request }) => {
25+
const transactionEventPromise = waitForTransaction('node-express-cjs-preload', transactionEvent => {
26+
return transactionEvent.contexts?.trace?.data?.['http.target'] === '/test-transaction/1';
27+
});
28+
29+
await request.get('/test-transaction/1');
30+
31+
const transactionEvent = await transactionEventPromise;
32+
33+
expect(transactionEvent).toBeDefined();
34+
expect(transactionEvent.transaction).toEqual('GET /test-transaction/:param');
35+
expect(transactionEvent.contexts?.trace?.data).toEqual(
36+
expect.objectContaining({
37+
'http.flavor': '1.1',
38+
'http.host': 'localhost:3030',
39+
'http.method': 'GET',
40+
'http.response.status_code': 200,
41+
'http.route': '/test-transaction/:param',
42+
'http.scheme': 'http',
43+
'http.status_code': 200,
44+
'http.status_text': 'OK',
45+
'http.target': '/test-transaction/1',
46+
'http.url': 'http://localhost:3030/test-transaction/1',
47+
'http.user_agent': expect.any(String),
48+
'net.host.ip': expect.any(String),
49+
'net.host.name': 'localhost',
50+
'net.host.port': 3030,
51+
'net.peer.ip': expect.any(String),
52+
'net.peer.port': expect.any(Number),
53+
'net.transport': 'ip_tcp',
54+
'otel.kind': 'SERVER',
55+
'sentry.op': 'http.server',
56+
'sentry.origin': 'auto.http.otel.http',
57+
'sentry.sample_rate': 1,
58+
'sentry.source': 'route',
59+
url: 'http://localhost:3030/test-transaction/1',
60+
}),
61+
);
62+
63+
const spans = transactionEvent.spans || [];
64+
expect(spans).toContainEqual({
65+
data: {
66+
'express.name': 'query',
67+
'express.type': 'middleware',
68+
'http.route': '/',
69+
'otel.kind': 'INTERNAL',
70+
'sentry.origin': 'auto.http.otel.express',
71+
'sentry.op': 'middleware.express',
72+
},
73+
op: 'middleware.express',
74+
description: 'query',
75+
origin: 'auto.http.otel.express',
76+
parent_span_id: expect.any(String),
77+
span_id: expect.any(String),
78+
start_timestamp: expect.any(Number),
79+
status: 'ok',
80+
timestamp: expect.any(Number),
81+
trace_id: expect.any(String),
82+
});
83+
84+
expect(spans).toContainEqual({
85+
data: {
86+
'express.name': 'expressInit',
87+
'express.type': 'middleware',
88+
'http.route': '/',
89+
'otel.kind': 'INTERNAL',
90+
'sentry.origin': 'auto.http.otel.express',
91+
'sentry.op': 'middleware.express',
92+
},
93+
op: 'middleware.express',
94+
description: 'expressInit',
95+
origin: 'auto.http.otel.express',
96+
parent_span_id: expect.any(String),
97+
span_id: expect.any(String),
98+
start_timestamp: expect.any(Number),
99+
status: 'ok',
100+
timestamp: expect.any(Number),
101+
trace_id: expect.any(String),
102+
});
103+
104+
expect(spans).toContainEqual({
105+
data: {
106+
'express.name': '/test-transaction/:param',
107+
'express.type': 'request_handler',
108+
'http.route': '/test-transaction/:param',
109+
'otel.kind': 'INTERNAL',
110+
'sentry.origin': 'auto.http.otel.express',
111+
'sentry.op': 'request_handler.express',
112+
},
113+
op: 'request_handler.express',
114+
description: '/test-transaction/:param',
115+
origin: 'auto.http.otel.express',
116+
parent_span_id: expect.any(String),
117+
span_id: expect.any(String),
118+
start_timestamp: expect.any(Number),
119+
status: 'ok',
120+
timestamp: expect.any(Number),
121+
trace_id: expect.any(String),
122+
});
123+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "node-express-esm-preload",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"start": "node --import @sentry/node/preload src/app.mjs",
7+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
8+
"test:build": "pnpm install",
9+
"test:assert": "playwright test"
10+
},
11+
"dependencies": {
12+
"@sentry/node": "latest || *",
13+
"@sentry/opentelemetry": "latest || *",
14+
"express": "4.19.2"
15+
},
16+
"devDependencies": {
17+
"@sentry-internal/event-proxy-server": "link:../../../event-proxy-server",
18+
"@playwright/test": "^1.27.1"
19+
},
20+
"volta": {
21+
"extends": "../../package.json",
22+
"node": "18.19.1"
23+
}
24+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { devices } from '@playwright/test';
2+
3+
// Fix urls not resolving to localhost on Node v17+
4+
// See: https://github.com/axios/axios/issues/3821#issuecomment-1413727575
5+
import { setDefaultResultOrder } from 'dns';
6+
setDefaultResultOrder('ipv4first');
7+
8+
const eventProxyPort = 3031;
9+
const expressPort = 3030;
10+
11+
/**
12+
* See https://playwright.dev/docs/test-configuration.
13+
*/
14+
const config = {
15+
testDir: './tests',
16+
/* Maximum time one test can run for. */
17+
timeout: 150_000,
18+
expect: {
19+
/**
20+
* Maximum time expect() should wait for the condition to be met.
21+
* For example in `await expect(locator).toHaveText();`
22+
*/
23+
timeout: 5000,
24+
},
25+
/* Run tests in files in parallel */
26+
fullyParallel: true,
27+
/* Fail the build on CI if you accidentally left test.only in the source code. */
28+
forbidOnly: !!process.env.CI,
29+
/* Retry on CI only */
30+
retries: 0,
31+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
32+
reporter: 'list',
33+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
34+
use: {
35+
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
36+
actionTimeout: 0,
37+
38+
/* Base URL to use in actions like `await page.goto('/')`. */
39+
baseURL: `http://localhost:${expressPort}`,
40+
},
41+
42+
/* Configure projects for major browsers */
43+
projects: [
44+
{
45+
name: 'chromium',
46+
use: {
47+
...devices['Desktop Chrome'],
48+
},
49+
},
50+
],
51+
52+
/* Run your local dev server before starting the tests */
53+
webServer: [
54+
{
55+
command: 'node start-event-proxy.mjs',
56+
port: eventProxyPort,
57+
stdout: 'pipe',
58+
stderr: 'pipe',
59+
},
60+
{
61+
command: 'pnpm start',
62+
port: expressPort,
63+
stdout: 'pipe',
64+
stderr: 'pipe',
65+
},
66+
],
67+
};
68+
69+
export default config;

0 commit comments

Comments
 (0)