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
67 changes: 64 additions & 3 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Affected SDKs: Server-side SDKs (`@sentry/node` and all dependents).

By default, v11 no longer sets up an OpenTelemetry tracer provider for **most** SDKs. SDKs now own the full span lifecycle, producing native Sentry spans.

A new optional OpenTelemetry integration lets you connect Sentry events such as Errors, Logs, Crons and Metrics to your OpenTelemetry traces, if you need to.
A new optional OpenTelemetry integration lets you connect Sentry events such as Errors, Logs, Crons and Metrics to your OpenTelemetry traces, if you need to. See [Connecting Sentry to your OpenTelemetry traces](#connecting-sentry-to-your-opentelemetry-traces).

Only `@sentry/nextjs` and `@sentry/sveltekit` still set up an OpenTelemetry compatible light tracer provider to capture spans the underlying frameworks emit.

Expand Down Expand Up @@ -111,8 +111,47 @@ With this, we also heavily reduced our OpenTelemetry dependencies, with `@opente

For most users, day-to-day tracing is **unchanged**.

> **TODO(v11):** Document the new optional OpenTelemetry integration once its final name and signature
> are locked in — add the `Sentry.init` example.
#### Connecting Sentry to your OpenTelemetry traces

`Sentry.otlpIntegration()` attaches everything Sentry sends that carries trace information (errors, logs, metrics and crons) to the OpenTelemetry span that is active when it happens. It takes no options, and is available from every server-side SDK, so there is nothing extra to install or import.

It does not set up a span exporter, span processor, or tracer provider. You keep full ownership of your OpenTelemetry pipeline, and outgoing request propagation is left to your OpenTelemetry propagator. To send your spans to Sentry, point your own exporter at the URL and auth headers that `Sentry.getOtlpTracesEndpoint()` derives from your DSN:

```js
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';

const provider = new NodeTracerProvider({
spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter(Sentry.getOtlpTracesEndpoint('__DSN__')))],
});

provider.register();

Sentry.init({
dsn: '__DSN__',
integrations: [Sentry.otlpIntegration()],
});
```

An active Sentry span still takes precedence, so this only changes what happens when Sentry has no span of its own, which is the usual setup when OpenTelemetry owns tracing.

If you used the v10 integration from `@sentry/node-core/light/otlp`, three things changed: it moved to the main export of every server SDK, it [no longer sets up an exporter for you and lost its options](#3-removed-apis), and it [reports itself as `Otlp` rather than `OtlpIntegration`](#otlpintegration-integration-renamed-to-otlp). Configure your own exporter as shown above, pointing it at your collector's URL if you route through one.

```js
// before
import * as Sentry from '@sentry/node-core/light';
import { otlpIntegration } from '@sentry/node-core/light/otlp';

Sentry.init({ dsn: '__DSN__', integrations: [otlpIntegration()] });

// after
import * as Sentry from '@sentry/node';

// set up your own tracer provider and exporter, then:
Sentry.init({ dsn: '__DSN__', integrations: [Sentry.otlpIntegration()] });
```

> **TODO(v11):** Link to the upcoming guide covering common use cases with the new OpenTelemetry setup
> (running your own OpenTelemetry setup alongside Sentry, connecting Sentry events to OTel traces, etc.).
Expand Down Expand Up @@ -625,6 +664,8 @@ Sentry.init({
- (AWS Lambda) The deprecated `startTrace` option was removed. It no longer had any effect; to disable tracing, set `tracesSampleRate` to `0`.
- (AWS Lambda) The deprecated `tryPatchHandler` function was removed. It was no longer used.
- (Express) The deprecated `patchExpressModule(options)` signature was removed. Use `patchExpressModule(moduleExports, getOptions)` instead.
- The `@sentry/node-core/light/otlp` entry point was removed, along with its optional `@opentelemetry/exporter-trace-otlp-http` peer dependency. `otlpIntegration` is now exported directly from every server-side SDK, so `Sentry.otlpIntegration()` needs no extra import or install.
- The `otlpIntegration` options `setupOtlpTracesExporter` and `collectorUrl` were removed, and the integration no longer sets up a span exporter, span processor, or tracer provider. Configure your own exporter and point it at `Sentry.getOtlpTracesEndpoint(dsn)`, or at your collector's URL if you route through one. See [Connecting Sentry to your OpenTelemetry traces](#connecting-sentry-to-your-opentelemetry-traces).

### `@sentry/cloudflare`

Expand Down Expand Up @@ -907,6 +948,26 @@ Several default integrations were renamed to match the names used by the other S
- `DenoMysql` => `Mysql`
- `DenoPostgres` => `Postgres`

### `OtlpIntegration` integration renamed to `Otlp`

Affected SDKs: Server-side SDKs (`@sentry/node` and all dependents).

The OTLP integration reports itself as `Otlp` rather than `OtlpIntegration`, matching every other integration in the SDKs, none of which carry an `Integration` suffix in their name. The `otlpIntegration()` export itself is unchanged. This only matters if you reference the integration by name:

```js
// before
Sentry.init({
integrations: integrations => integrations.filter(integration => integration.name !== 'OtlpIntegration'),
});

// after
Sentry.init({
integrations: integrations => integrations.filter(integration => integration.name !== 'Otlp'),
});
```

The same applies when looking the integration up by name, e.g. via `client.getIntegrationByName('OtlpIntegration')`.

## 6. Type Changes

- Several public types that used `any` now use `unknown` — including `StackFrame`, `SamplingContext`,
Expand Down
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,100 @@
import { expect, test } from '@playwright/test';
import { waitForEnvelopeItem, waitForError, waitForMetric, waitForRequest } 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('sends no envelope trace header while riding along on an OpenTelemetry span', async ({ baseURL }) => {
const envelopePromise = waitForRequest('node-express-otlp', ({ envelope }) => {
const [, items] = envelope;
return items.some(
item =>
(item[1] as { exception?: { values?: { value?: string }[] } })?.exception?.values?.[0]?.value ===
'This is an exception with id 567',
);
});

await triggerTelemetry(baseURL as string, '567');
const { envelope } = await envelopePromise;
const [envelopeHeaders] = envelope;

// The Sentry scope's sampling context describes a different trace than the OpenTelemetry one the
// event is stamped with, so no `trace` header is sent rather than one naming the wrong trace.
expect((envelopeHeaders as { trace?: unknown }).trace).toBeUndefined();
});

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
Loading
Loading