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,28 @@
{
"name": "node-otel-sdk-node-v3",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node --import ./src/instrument.mjs src/app.mjs",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install",
"test:assert": "playwright test"
},
"dependencies": {
"@opentelemetry/api": "^1.10.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.300.0",
"@opentelemetry/instrumentation": "^0.300.0",
"@opentelemetry/instrumentation-http": "^0.300.0",
"@opentelemetry/sdk-node": "^0.300.0",
"@opentelemetry/sdk-trace": "^3.0.0",
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
"express": "^4.21.2"
},
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz"
},
"volta": {
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const config = getPlaywrightConfig(
{
startCommand: `pnpm start`,
},
{
webServer: [
{
command: `node ./start-event-proxy.mjs`,
port: 3031,
stdout: 'pipe',
stderr: 'pipe',
},
{
command: `node ./start-otel-proxy.mjs`,
port: 3032,
stdout: 'pipe',
stderr: 'pipe',
},
{
command: 'pnpm start',
port: 3030,
stdout: 'pipe',
stderr: 'pipe',
env: {
PORT: 3030,
},
},
],
},
);

export default config;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
allowBuilds:
protobufjs: false
minimumReleaseAge: 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { trace } from '@opentelemetry/api';
import * as Sentry from '@sentry/node';
import express from 'express';
import http from 'node:http';

const app = express();
const port = 3030;
const tracer = trace.getTracer('node-otel-sdk-node-v3');

app.get('/test-telemetry/:id', function (req, res) {
tracer.startActiveSpan('telemetry-handler', span => {
const { traceId, spanId } = span.spanContext();

Sentry.captureException(new Error(`This is an exception with id ${req.params.id}`));

span.end();

res.json({ traceId, spanId });
});
});

app.get('/test-exception/:id', function (req, _res) {
throw new Error(`This is an exception with id ${req.params.id}`);
});

app.get('/test-outgoing', function (_req, res) {
http.get(`http://localhost:${port}/echo-headers`, response => {
let body = '';
response.on('data', chunk => (body += chunk));
response.on('end', () => res.type('json').send(body));
});
});

app.get('/echo-headers', function (req, res) {
res.send(req.headers);
});

// Answers with the OpenTelemetry span the request ran under, so the test can check what the error
// captured by Sentry was linked to.
app.use(function onError(_err, _req, res, _next) {
const spanContext = trace.getActiveSpan()?.spanContext();
res.status(500).json({ traceId: spanContext?.traceId, spanId: spanContext?.spanId });
});

app.listen(port, () => {
console.log(`Example app listening on port ${port}`);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace';
import * as Sentry from '@sentry/node';
import { register } from 'node:module';

// What an ESM app needs for the OpenTelemetry instrumentation to see the modules it patches. It
// puts import-in-the-middle in the process next to the module hooks Sentry's channel injection
// registers from `Sentry.init()` below, which is the pairing this app exists to cover.
register('@opentelemetry/instrumentation/hook.mjs', import.meta.url);

// OpenTelemetry owns tracing here: the user's own SDK, their own instrumentation and their own
// exporter. In production the exporter would point at `Sentry.getOtlpTracesEndpoint(dsn)`; here it
// points at a local receiver so the test can assert what was exported.
const sdk = new NodeSDK({
instrumentations: [new HttpInstrumentation()],
spanProcessors: [
new BatchSpanProcessor({
exporter: new OTLPTraceExporter({ url: 'http://localhost:3032/' }),
scheduledDelayMillis: 100,
}),
],
});

sdk.start();

Sentry.init({
environment: 'qa',
dsn: process.env.E2E_TEST_DSN,
debug: !!process.env.DEBUG,
tunnel: `http://localhost:3031/`, // proxy server
// no tracesSampleRate: OpenTelemetry owns spans, Sentry owns errors and logs
integrations: [Sentry.openTelemetryIntegration()],
});
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: 'node-otel-sdk-node-v3',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startProxyServer } from '@sentry-internal/test-utils';

startProxyServer({
port: 3032,
proxyServerName: 'node-otel-sdk-node-v3-otel',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test('links errors to the active OpenTelemetry span', async ({ baseURL }) => {
const errorEventPromise = waitForError('node-otel-sdk-node-v3', errorEvent => {
return errorEvent.exception?.values?.[0]?.value === 'This is an exception with id 123';
});

const response = await fetch(`${baseURL}/test-telemetry/123`);
const { traceId, spanId } = (await response.json()) as { traceId: string; spanId: string };

const errorEvent = await errorEventPromise;

expect(errorEvent.contexts?.trace).toEqual({ trace_id: traceId, span_id: spanId });
});

test('links errors from the Sentry instrumentation to the active OpenTelemetry span', async ({ baseURL }) => {
const errorEventPromise = waitForError('node-otel-sdk-node-v3', errorEvent => {
return errorEvent.exception?.values?.[0]?.value === 'This is an exception with id 456';
});

const response = await fetch(`${baseURL}/test-exception/456`);
const { traceId, spanId } = (await response.json()) as { traceId: string; spanId: string };

const errorEvent = await errorEventPromise;

// With tracing off, Sentry's channel instrumentation still runs and reports the errors express
// never handles.
expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ type: 'auto.http.express', handled: false });
expect(errorEvent.contexts?.trace).toEqual({ trace_id: traceId, span_id: spanId });
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForPlainRequest, waitForTransaction } from '@sentry-internal/test-utils';

interface OtlpSpan {
name: string;
traceId: string;
}

/**
* The proxy hands back the payloads it received as newline separated JSON, so a single read can
* hold more than one OTLP export.
*/
function getExportedSpans(data: string): OtlpSpan[] {
return data
.split('\n')
.filter(Boolean)
.flatMap(
line => (JSON.parse(line) as { resourceSpans?: { scopeSpans?: { spans?: OtlpSpan[] }[] }[] }).resourceSpans ?? [],
)
.flatMap(resourceSpan => resourceSpan.scopeSpans ?? [])
.flatMap(scopeSpan => scopeSpan.spans ?? []);
}

test('exports the spans of the user OpenTelemetry setup to their own collector', async ({ baseURL }) => {
let traceId: string | undefined;

// Every test in this app exports into the same proxy, and the http span only ends once the
// response is out, so wait for this request's own trace to be complete. Waiting on the span names
// alone matches a batch that another test filled, whose spans are on a different trace.
const otelExportPromise = waitForPlainRequest('node-otel-sdk-node-v3-otel', data => {
const names = getExportedSpans(data)
.filter(span => span.traceId === traceId)
.map(span => span.name);
return names.includes('telemetry-handler') && names.includes('GET');
});

const response = await fetch(`${baseURL}/test-telemetry/234`);
({ traceId } = (await response.json()) as { traceId: string });

const exportedSpans = getExportedSpans(await otelExportPromise).filter(span => span.traceId === traceId);

// The user's own http instrumentation keeps working and stays on the same trace as their spans.
expect(exportedSpans.map(span => span.name)).toEqual(expect.arrayContaining(['telemetry-handler', 'GET']));
});

test('sends no spans to Sentry', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('node-otel-sdk-node-v3', () => true);
const errorPromise = waitForError('node-otel-sdk-node-v3', errorEvent => {
return errorEvent.exception?.values?.[0]?.value === 'This is an exception with id 345';
});

await fetch(`${baseURL}/test-telemetry/345`);
// Proves the request's telemetry reached the proxy, so the absence check below is not vacuous.
await errorPromise;

// Absence can only be time bounded. This guards against Sentry's own instrumentation emitting
// spans again, which would produce a transaction for every request, well inside this window.
const transaction = await Promise.race([
transactionPromise,
new Promise(resolve => setTimeout(() => resolve(undefined), 3000)),
]);

expect(transaction).toBeUndefined();
});

test('leaves outgoing trace propagation to the user propagator', async ({ baseURL }) => {
const response = await fetch(`${baseURL}/test-outgoing`);
const headers = (await response.json()) as Record<string, string>;

expect(headers['traceparent']).toMatch(/^00-[a-f0-9]{32}-[a-f0-9]{16}-\d{2}$/);
expect(headers['sentry-trace']).toBeUndefined();
expect(headers['baggage']).toBeUndefined();
});
Loading