Skip to content
Open
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 @@
dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "node-express-otlp-app",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tsc",
"start": "node dist/app.js",
"test": "playwright test",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm test"
},
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
"@opentelemetry/sdk-trace-base": "^2.9.0",
"@opentelemetry/sdk-trace-node": "^2.9.0",
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
"@types/express": "^4.17.21",
"@types/node": "^18.19.1",
"express": "^4.21.2",
"typescript": "~5.0.0"
},
"devDependencies": {
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils"
},
"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,95 @@
import { trace } from '@opentelemetry/api';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import * as Sentry from '@sentry/node';
import express from 'express';

const dsn = process.env.E2E_TEST_DSN as string;
const appPort = 3030;
const otlpReceiverPort = 3033;

const otlpTracesEndpoint = Sentry.getOtlpTracesEndpoint(dsn);
if (!otlpTracesEndpoint) {
throw new Error(`Could not derive an OTLP traces endpoint from E2E_TEST_DSN: ${dsn}`);
}

// The user brings their own OpenTelemetry setup. In production `url` would be
// `otlpTracesEndpoint.url`; here it points at the local receiver below so the test can assert what
// was actually exported. The auth headers are the real DSN-derived ones either way.
const provider = new NodeTracerProvider({
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter({
url: `http://localhost:${otlpReceiverPort}/v1/traces`,
headers: otlpTracesEndpoint.headers,
}),
{ scheduledDelayMillis: 100 },
),
],
});

provider.register();

Sentry.init({
dsn,
debug: !!process.env.DEBUG,
tunnel: `http://localhost:3031/`, // proxy server
integrations: [Sentry.otlpIntegration()],
});

interface ExportedTrace {
traceId: string;
spanIds: string[];
sentryAuthHeader?: string;
}

const exportedTraces: ExportedTrace[] = [];

const otlpReceiver = express();
otlpReceiver.use(express.json({ limit: '10mb' }));

otlpReceiver.post('/v1/traces', (req, res) => {
const sentryAuthHeader = req.header('x-sentry-auth');

for (const resourceSpan of req.body?.resourceSpans ?? []) {
for (const scopeSpan of resourceSpan.scopeSpans ?? []) {
for (const span of scopeSpan.spans ?? []) {
const existing = exportedTraces.find(entry => entry.traceId === span.traceId);
if (existing) {
existing.spanIds.push(span.spanId);
} else {
exportedTraces.push({ traceId: span.traceId, spanIds: [span.spanId], sentryAuthHeader });
}
}
}
}

res.json({});
});

otlpReceiver.listen(otlpReceiverPort);

const app = express();
const tracer = trace.getTracer('node-express-otlp');

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

Sentry.logger.info(`This is a log with id ${id}`);
Sentry.metrics.count('otlp.test.count', 1, { attributes: { id } });
Sentry.captureException(new Error(`This is an exception with id ${id}`));

span.end();

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

app.get('/otlp-exported-traces', (_req, res) => {
res.json(exportedTraces);
});

app.listen(appPort);
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-express-otlp',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test';
import { waitForEnvelopeItem, waitForError, waitForMetric } from '@sentry-internal/test-utils';
import type { SerializedLogContainer } from '@sentry/core';

interface ExportedTrace {
traceId: string;
spanIds: string[];
sentryAuthHeader?: string;
}

async function waitForExportedTrace(baseURL: string, traceId: string): Promise<ExportedTrace> {
const deadline = Date.now() + 15_000;

while (Date.now() < deadline) {
const response = await fetch(`${baseURL}/otlp-exported-traces`);
const exportedTraces = (await response.json()) as ExportedTrace[];

const match = exportedTraces.find(entry => entry.traceId === traceId);
if (match) {
return match;
}

await new Promise(resolve => setTimeout(resolve, 200));
}

throw new Error(`Trace ${traceId} was never exported over OTLP`);
}

async function triggerTelemetry(baseURL: string, id: string): Promise<{ traceId: string; spanId: string }> {
const response = await fetch(`${baseURL}/test-telemetry/${id}`);
return (await response.json()) as { traceId: string; spanId: string };
}

test('attaches the active OpenTelemetry trace to errors', async ({ baseURL }) => {
const errorEventPromise = waitForError('node-express-otlp', event => {
return event.exception?.values?.[0]?.value === 'This is an exception with id 123';
});

const { traceId, spanId } = await triggerTelemetry(baseURL as string, '123');
const errorEvent = await errorEventPromise;

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

test('attaches the active OpenTelemetry trace to logs', async ({ baseURL }) => {
const logEnvelopePromise = waitForEnvelopeItem('node-express-otlp', envelope => {
return (
envelope[0].type === 'log' &&
(envelope[1] as SerializedLogContainer).items.some(item => item.body === 'This is a log with id 234')
);
});

const { traceId } = await triggerTelemetry(baseURL as string, '234');
const logEnvelope = await logEnvelopePromise;

const log = (logEnvelope[1] as SerializedLogContainer).items.find(item => item.body === 'This is a log with id 234');
expect(log?.trace_id).toBe(traceId);
});

test('attaches the active OpenTelemetry trace to metrics', async ({ baseURL }) => {
const metricPromise = waitForMetric('node-express-otlp', metric => {
return metric.name === 'otlp.test.count' && metric.attributes?.id?.value === '345';
});

const { traceId } = await triggerTelemetry(baseURL as string, '345');
const metric = await metricPromise;

expect(metric.trace_id).toBe(traceId);
});

test('exports spans over OTLP with the DSN-derived auth header', async ({ baseURL }) => {
const { traceId, spanId } = await triggerTelemetry(baseURL as string, '456');

const exportedTrace = await waitForExportedTrace(baseURL as string, traceId);

expect(exportedTrace.spanIds).toContain(spanId);
expect(exportedTrace.sentryAuthHeader).toMatch(/^Sentry sentry_version=7, sentry_key=\w+$/);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"types": ["node"],
"esModuleInterop": true,
"lib": ["es2018"],
"strict": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
2 changes: 2 additions & 0 deletions packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export {
postgresIntegration,
postgresJsIntegration,
prismaIntegration,
otlpIntegration,
getOtlpTracesEndpoint,
processSessionIntegration,
childProcessIntegration,
createSentryWinstonTransport,
Expand Down
2 changes: 2 additions & 0 deletions packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export {
postgresJsIntegration,
processSessionIntegration,
prismaIntegration,
otlpIntegration,
getOtlpTracesEndpoint,
childProcessIntegration,
createSentryWinstonTransport,
hapiIntegration,
Expand Down
2 changes: 2 additions & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export {
postgresIntegration,
postgresJsIntegration,
prismaIntegration,
otlpIntegration,
getOtlpTracesEndpoint,
processSessionIntegration,
hapiIntegration,
setupHapiErrorHandler,
Expand Down
2 changes: 2 additions & 0 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ export { fetchIntegration } from './integrations/fetch';
export { spotlightIntegration } from './integrations/spotlight';
export { vercelAIIntegration } from './integrations/tracing/vercelai';
export {
otlpIntegration,
getOtlpTracesEndpoint,
prismaIntegration,
instrumentOpenAiClient,
instrumentAnthropicAiClient,
Expand Down
1 change: 1 addition & 0 deletions packages/deno/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export {
tediousIntegration,
vercelAiIntegration,
} from '@sentry/server-utils/orchestrion';
export { otlpIntegration, getOtlpTracesEndpoint } from '@sentry/server-utils/no-diagnostic-channels';
// Deprecated aliases kept for back-compat. Each forwards to the shared
// integration above, so its name is the shared name (e.g. `Mysql`), not the old
// `Deno*` name. See each alias's `@deprecated` note.
Expand Down
2 changes: 2 additions & 0 deletions packages/google-cloud-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ export {
postgresIntegration,
postgresJsIntegration,
prismaIntegration,
otlpIntegration,
getOtlpTracesEndpoint,
processSessionIntegration,
hapiIntegration,
setupHapiErrorHandler,
Expand Down
2 changes: 2 additions & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export {
} from '@sentry/server-utils/orchestrion';
export { redisIntegration } from './integrations/tracing/redis';
export {
otlpIntegration,
getOtlpTracesEndpoint,
prismaIntegration,
instrumentOpenAiClient,
instrumentAnthropicAiClient,
Expand Down
1 change: 1 addition & 0 deletions packages/server-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"access": "public"
},
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.67.0"
},
Expand Down
1 change: 1 addition & 0 deletions packages/server-utils/src/exports.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Shared exports not using diagnostics channels
export { setHttpServerSpanRouteAttribute } from './utils/setHttpServerSpanRouteAttribute';
export { setAsyncLocalStorageAsyncContextStrategy } from './async-context';
export { otlpIntegration, getOtlpTracesEndpoint } from './otlp';
export * from './ai';
Loading
Loading