Skip to content

Commit bc7bc13

Browse files
authored
chore(node-core): Add node-core otel v1 and v2 apps (#17214)
1 parent 6b23b27 commit bc7bc13

Some content is hidden

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

44 files changed

+1274
-0
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
dist
2+
.vscode
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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "node-core-express-otel-v1-custom-sampler",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"build": "tsc",
7+
"start": "node dist/app.js",
8+
"test": "playwright test",
9+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
10+
"test:build": "pnpm install && pnpm build",
11+
"test:assert": "pnpm test"
12+
},
13+
"dependencies": {
14+
"@opentelemetry/api": "^1.9.0",
15+
"@opentelemetry/context-async-hooks": "^1.30.1",
16+
"@opentelemetry/core": "^1.30.1",
17+
"@opentelemetry/instrumentation": "^0.57.1",
18+
"@opentelemetry/instrumentation-http": "^0.57.1",
19+
"@opentelemetry/resources": "^1.30.1",
20+
"@opentelemetry/sdk-trace-node": "^1.30.1",
21+
"@opentelemetry/semantic-conventions": "^1.30.0",
22+
"@sentry/node-core": "latest || *",
23+
"@sentry/opentelemetry": "latest || *",
24+
"@types/express": "4.17.17",
25+
"@types/node": "^18.19.1",
26+
"express": "4.19.2",
27+
"typescript": "~5.0.0"
28+
},
29+
"devDependencies": {
30+
"@playwright/test": "~1.53.2",
31+
"@sentry-internal/test-utils": "link:../../../test-utils"
32+
},
33+
"volta": {
34+
"extends": "../../package.json"
35+
}
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const config = getPlaywrightConfig({
4+
startCommand: `pnpm start`,
5+
});
6+
7+
export default config;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import './instrument';
2+
3+
import * as Sentry from '@sentry/node-core';
4+
import express from 'express';
5+
6+
const PORT = 3030;
7+
const app = express();
8+
9+
const wait = (duration: number) => {
10+
return new Promise<void>(res => {
11+
setTimeout(() => res(), duration);
12+
});
13+
};
14+
15+
app.get('/task', async (_req, res) => {
16+
await Sentry.startSpan({ name: 'Long task', op: 'custom.op' }, async () => {
17+
await wait(200);
18+
});
19+
res.send('ok');
20+
});
21+
22+
app.get('/unsampled/task', async (_req, res) => {
23+
await wait(200);
24+
res.send('ok');
25+
});
26+
27+
app.get('/test-error', async function (req, res) {
28+
const exceptionId = Sentry.captureException(new Error('This is an error'));
29+
30+
await Sentry.flush(2000);
31+
32+
res.send({ exceptionId });
33+
});
34+
35+
app.get('/test-exception/:id', function (req, _res) {
36+
throw new Error(`This is an exception with id ${req.params.id}`);
37+
});
38+
39+
app.use(function onError(err: unknown, req: any, res: any, next: any) {
40+
// Explicitly capture the error with Sentry
41+
Sentry.captureException(err);
42+
43+
// The error id is attached to `res.sentry` to be returned
44+
// and optionally displayed to the user for support.
45+
res.statusCode = 500;
46+
res.end(res.sentry + '\n');
47+
});
48+
49+
app.listen(PORT, () => {
50+
console.log('App listening on ', PORT);
51+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Attributes, Context, Link, SpanKind } from '@opentelemetry/api';
2+
import { Sampler, SamplingResult } from '@opentelemetry/sdk-trace-node';
3+
import { wrapSamplingDecision } from '@sentry/opentelemetry';
4+
5+
export class CustomSampler implements Sampler {
6+
public shouldSample(
7+
context: Context,
8+
_traceId: string,
9+
_spanName: string,
10+
_spanKind: SpanKind,
11+
attributes: Attributes,
12+
_links: Link[],
13+
): SamplingResult {
14+
const route = attributes['http.route'];
15+
const target = attributes['http.target'];
16+
const decision =
17+
(typeof route === 'string' && route.includes('/unsampled')) ||
18+
(typeof target === 'string' && target.includes('/unsampled'))
19+
? 0
20+
: 1;
21+
return wrapSamplingDecision({
22+
decision,
23+
context,
24+
spanAttributes: attributes,
25+
});
26+
}
27+
28+
public toString(): string {
29+
return CustomSampler.name;
30+
}
31+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
2+
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
3+
import * as Sentry from '@sentry/node-core';
4+
import { SentryPropagator, SentrySpanProcessor } from '@sentry/opentelemetry';
5+
import { CustomSampler } from './custom-sampler';
6+
7+
Sentry.init({
8+
environment: 'qa', // dynamic sampling bias to keep transactions
9+
dsn: process.env.E2E_TEST_DSN,
10+
includeLocalVariables: true,
11+
debug: !!process.env.DEBUG,
12+
tunnel: `http://localhost:3031/`, // proxy server
13+
tracesSampleRate: 1,
14+
openTelemetryInstrumentations: [new HttpInstrumentation()],
15+
});
16+
17+
const provider = new NodeTracerProvider({
18+
sampler: new CustomSampler(),
19+
spanProcessors: [new SentrySpanProcessor()],
20+
});
21+
22+
provider.register({
23+
propagator: new SentryPropagator(),
24+
contextManager: new Sentry.SentryContextManager(),
25+
});
26+
27+
Sentry.validateOpenTelemetrySetup();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'node-core-express-otel-v1-custom-sampler',
6+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError } from '@sentry-internal/test-utils';
3+
4+
test('Sends correct error event', async ({ baseURL }) => {
5+
const errorEventPromise = waitForError('node-core-express-otel-v1-custom-sampler', event => {
6+
return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123';
7+
});
8+
9+
await fetch(`${baseURL}/test-exception/123`);
10+
11+
const errorEvent = await errorEventPromise;
12+
13+
expect(errorEvent.exception?.values).toHaveLength(1);
14+
expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123');
15+
16+
expect(errorEvent.request).toEqual({
17+
method: 'GET',
18+
cookies: {},
19+
headers: expect.any(Object),
20+
url: 'http://localhost:3030/test-exception/123',
21+
});
22+
23+
// For node-core without Express integration, transaction name is the actual URL
24+
expect(errorEvent.transaction).toEqual('GET /test-exception/123');
25+
26+
expect(errorEvent.contexts?.trace).toEqual({
27+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
28+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
29+
});
30+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('Sends a sampled API route transaction', async ({ baseURL }) => {
5+
const transactionEventPromise = waitForTransaction('node-core-express-otel-v1-custom-sampler', transactionEvent => {
6+
return transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /task';
7+
});
8+
9+
await fetch(`${baseURL}/task`);
10+
11+
const transactionEvent = await transactionEventPromise;
12+
13+
expect(transactionEvent.contexts?.trace).toEqual({
14+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
15+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
16+
data: {
17+
'sentry.source': 'url',
18+
'sentry.op': 'http.server',
19+
'sentry.origin': 'manual',
20+
url: 'http://localhost:3030/task',
21+
'otel.kind': 'SERVER',
22+
'http.response.status_code': 200,
23+
'http.url': 'http://localhost:3030/task',
24+
'http.host': 'localhost:3030',
25+
'net.host.name': 'localhost',
26+
'http.method': 'GET',
27+
'http.scheme': 'http',
28+
'http.target': '/task',
29+
'http.user_agent': 'node',
30+
'http.flavor': '1.1',
31+
'net.transport': 'ip_tcp',
32+
'net.host.ip': expect.any(String),
33+
'net.host.port': 3030,
34+
'net.peer.ip': expect.any(String),
35+
'net.peer.port': expect.any(Number),
36+
'http.status_code': 200,
37+
'http.status_text': 'OK',
38+
},
39+
origin: 'manual',
40+
op: 'http.server',
41+
status: 'ok',
42+
});
43+
44+
expect(transactionEvent.spans?.length).toBe(1);
45+
46+
expect(transactionEvent.spans).toContainEqual({
47+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
48+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
49+
data: {
50+
'sentry.origin': 'manual',
51+
'sentry.op': 'custom.op',
52+
},
53+
description: 'Long task',
54+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
55+
start_timestamp: expect.any(Number),
56+
timestamp: expect.any(Number),
57+
status: 'ok',
58+
op: 'custom.op',
59+
origin: 'manual',
60+
});
61+
});
62+
63+
test('Does not send an unsampled API route transaction', async ({ baseURL }) => {
64+
const unsampledTransactionEventPromise = waitForTransaction(
65+
'node-core-express-otel-v1-custom-sampler',
66+
transactionEvent => {
67+
return (
68+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
69+
transactionEvent?.transaction === 'GET /unsampled/task'
70+
);
71+
},
72+
);
73+
74+
await fetch(`${baseURL}/unsampled/task`);
75+
76+
const promiseShouldNotResolve = () =>
77+
new Promise<void>((resolve, reject) => {
78+
const timeout = setTimeout(() => {
79+
resolve(); // Test passes because promise did not resolve within timeout
80+
}, 1000);
81+
82+
unsampledTransactionEventPromise.then(
83+
() => {
84+
clearTimeout(timeout);
85+
reject(new Error('Promise should not have resolved'));
86+
},
87+
() => {
88+
clearTimeout(timeout);
89+
reject(new Error('Promise should not have been rejected'));
90+
},
91+
);
92+
});
93+
94+
expect(promiseShouldNotResolve()).resolves.not.toThrow();
95+
});

0 commit comments

Comments
 (0)