Skip to content
42 changes: 42 additions & 0 deletions docs/migration/v11-end-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,21 @@ Sentry.httpIntegration({

`httpIntegration`'s `instrumentation` option is still honored for **outgoing** request spans.

### Deno `node:http` server requests are tracked as sessions

Affected SDKs: `@sentry/deno`.

`denoHttpIntegration` now creates [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming `node:http` requests, matching the other server SDKs. In v10 it disabled them unconditionally, so release health reported no session data for Deno servers. If you have a `release` configured, you will start seeing session aggregates for incoming requests. Pass `sessions: false` to restore the previous behavior:

```js
Sentry.init({
dsn: '__DSN__',
integrations: [Sentry.denoHttpIntegration({ sessions: false })],
});
```

`sessionFlushingDelayMS` is also configurable now, and defaults to `60000` (60s) as in the other SDKs.

### Node HTTP transport `keepAlive` defaults to `true`

Affected SDKs: `@sentry/node` and dependents.
Expand Down Expand Up @@ -1268,6 +1283,33 @@ Several default integrations were renamed to match the names used by the other S
- `DenoMysql` => `Mysql`
- `DenoPostgres` => `Postgres`

### `denoHttpIntegration` incoming span hooks renamed

Affected SDKs: `@sentry/deno`.

The incoming-span hooks on `denoHttpIntegration` were renamed to match `httpIntegration` in the other server SDKs. Their arguments are typed as `HttpIncomingMessage` / `HttpServerResponse` now, instead of `unknown`.

| Removed option | Replacement |
| ----------------------- | --------------- |
| `onIncomingSpanCreated` | `onSpanCreated` |
| `onIncomingSpanEnd` | `onSpanEnd` |

```js
// before
Sentry.denoHttpIntegration({
onIncomingSpanCreated: (span, req, res) => {
span.setAttribute('custom', true);
},
});

// after
Sentry.denoHttpIntegration({
onSpanCreated: (span, req, res) => {
span.setAttribute('custom', true);
},
});
```

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

Affected SDKs: Server-side SDKs (`@sentry/node` and all dependents).
Expand Down
38 changes: 23 additions & 15 deletions packages/deno/src/integrations/http.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { subscribe } from 'node:diagnostics_channel';
import { errorMonitor } from 'node:events';
import type { RequestOptions } from 'node:http';
import type { HttpIncomingMessage, Integration, IntegrationFn, Span } from '@sentry/core';
import type { HttpIncomingMessage, HttpServerResponse, Integration, IntegrationFn, Span } from '@sentry/core';
import {
defineIntegration,
getHttpClientSubscriptions,
Expand All @@ -27,6 +27,21 @@ export interface DenoHttpIntegrationOptions {
*/
spans?: boolean;

/**
* Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for
* incoming requests to track the health and crash-free rate of your releases in Sentry.
*
* @default `true`
*/
sessions?: boolean;

/**
* Number of milliseconds until sessions are flushed as a session aggregate.
*
* @default `60000` (60s)
*/
sessionFlushingDelayMS?: number;

/**
* Whether to inject trace propagation headers (sentry-trace, baggage) into outgoing HTTP requests.
*
Expand Down Expand Up @@ -77,14 +92,15 @@ export interface DenoHttpIntegrationOptions {
ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean;

/**
* Hook invoked after the server span is created but before the request is handled.
* A hook that can be used to mutate the span for incoming requests.
* This is triggered after the span is created, but before it is recorded.
*/
onIncomingSpanCreated?: (span: Span, request: unknown, response: unknown) => void;
onSpanCreated?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void;

/**
* Hook invoked when the server span ends, before it is recorded.
* A hook that can be used to mutate the span one last time when the response is finished.
*/
onIncomingSpanEnd?: (span: Span, request: unknown, response: unknown) => void;
onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void;
}

const _denoHttpIntegration = ((options: DenoHttpIntegrationOptions = {}) => {
Expand All @@ -95,21 +111,13 @@ const _denoHttpIntegration = ((options: DenoHttpIntegrationOptions = {}) => {
name: INTEGRATION_NAME,
setupOnce() {
const { [HTTP_ON_SERVER_REQUEST]: onHttpServerRequest } = getHttpServerSubscriptions({
// `spans` falls through to the client's tracing config when unset.
spans: options.spans,
ignoreStaticAssets: options.ignoreStaticAssets,
ignoreIncomingRequests: options.ignoreIncomingRequests,
Comment thread
cursor[bot] marked this conversation as resolved.
maxRequestBodySize: options.maxRequestBodySize,
ignoreRequestBody: options.ignoreRequestBody,
onSpanCreated: options.onIncomingSpanCreated,
onSpanEnd: options.onIncomingSpanEnd,
...options,
errorMonitor,
sessions: false,
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
});
subscribe(HTTP_ON_SERVER_REQUEST, onHttpServerRequest);
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
isaacs marked this conversation as resolved.

const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequest } = getHttpClientSubscriptions({
Comment thread
sentry[bot] marked this conversation as resolved.
spans: options.spans,
...options,
breadcrumbs,
propagateTrace: tracePropagation,
ignoreOutgoingRequests: options.ignoreOutgoingRequests
Expand Down
64 changes: 64 additions & 0 deletions packages/deno/test/deno-http-sessions-disabled.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// <reference lib="deno.ns" />

/**
* Lives in its own file because `setupOnce` runs once per process
* (`installedIntegrations` guards it) and the diagnostics channel
* subscription is global. Deno gives each test file a fresh module graph,
* so this is the only way to install `denoHttpIntegration` with
* non-default options after `deno-http.test.ts` has installed it with
* the defaults.
*/

import * as http from 'node:http';
import type { Envelope } from '@sentry/core';
import { forEachEnvelopeItem, getIsolationScope, getMainCarrier } from '@sentry/core';
import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts';
import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts';
import { denoHttpIntegration, init } from '../build/esm/index.js';
import { makeTestTransport } from './transport.ts';

Deno.test({
name: 'denoHttpIntegration: node:http incoming request records no session when sessions: false',
async fn() {
getMainCarrier().__SENTRY__ = undefined;

const envelopes: Envelope[] = [];
const client = init({
dsn: 'https://username@domain/123',
release: '1.0.0',
integrations: [denoHttpIntegration({ sessions: false })],
transport: makeTestTransport(envelope => {
envelopes.push(envelope);
}),
});

// Captured inside the handler so we can tell "sessions were disabled"
// apart from "the request was never instrumented at all".
let isolatedTransactionName: string | undefined;
const server = http.createServer((_req, res) => {
isolatedTransactionName = getIsolationScope().getScopeData().transactionName;
res.end('ok');
});
const port: number = await new Promise(resolve => {
server.listen(0, '127.0.0.1', () => {
resolve((server.address() as { port: number }).port);
});
});

const response = await fetch(`http://127.0.0.1:${port}/health`);
assertEquals(await response.text(), 'ok');
await new Promise<void>(resolve => server.close(() => resolve()));
await client.flush(2_000);

const itemTypes: string[] = [];
for (const envelope of envelopes) {
forEachEnvelopeItem(envelope, ([headers]) => {
itemTypes.push(headers.type);
});
}

assertEquals(isolatedTransactionName, 'GET /health');
assert(!itemTypes.includes('sessions'), `expected no session envelope item, got: ${itemTypes.join(', ')}`);
assert(!itemTypes.includes('session'), `expected no session envelope item, got: ${itemTypes.join(', ')}`);
},
});
130 changes: 130 additions & 0 deletions packages/deno/test/deno-http-spans-disabled.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// <reference lib="deno.ns" />

/**
* Lives in its own file because `setupOnce` runs once per process
* (`installedIntegrations` guards it) and the diagnostics channel
* subscription is global. Deno gives each test file a fresh module graph,
* so this is the only way to install `denoHttpIntegration` with
* `spans: false` after another file has installed it with the defaults.
*
* Both tests below share that single `spans: false` subscription.
*/

import * as http from 'node:http';
import type { TransactionEvent } from '@sentry/core';
import { getIsolationScope, getMainCarrier } from '@sentry/core';
import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts';
import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts';
import type { DenoClient } from '../build/esm/index.js';
import { denoHttpIntegration, init, startSpan } from '../build/esm/index.js';

/**
* `spans: false` must win over `tracesSampleRate: 1`, so tracing is on
* everywhere except the HTTP integration. Without it the option would be
* indistinguishable from tracing being off.
*/
function initWithSpansDisabled(transactions: TransactionEvent[]): DenoClient {
getMainCarrier().__SENTRY__ = undefined;
return init({
dsn: 'https://username@domain/123',
tracesSampleRate: 1,
traceLifecycle: 'static',
integrations: [denoHttpIntegration({ spans: false })],
beforeSendTransaction: (event: TransactionEvent) => {
transactions.push(event);
return null;
},
}) as DenoClient;
}

Deno.test({
name: 'denoHttpIntegration: node:http outgoing request creates no http.client span when spans: false',
async fn() {
const transactions: TransactionEvent[] = [];
const client = initWithSpansDisabled(transactions);

// Deno.serve for the target so this does not depend on the node:http
// server instrumentation.
const abortController = new AbortController();
let onListen: ((_: unknown) => void) | undefined;
const listening = new Promise(resolve => (onListen = resolve));
// Captured so we can tell "spans were disabled" apart from "the client
// was never instrumented at all" -- header injection survives spans: false.
let sentryTraceHeader: string | null = null;
const target = Deno.serve(
{ port: 0, signal: abortController.signal, onListen, hostname: '127.0.0.1' },
(request: Request) => {
sentryTraceHeader = request.headers.get('sentry-trace');
return new Response('pong');
},
);
await listening;

await startSpan({ name: 'parent', op: 'test' }, async () => {
await new Promise<void>((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port: target.addr.port, path: '/ping', method: 'GET' }, res => {
res.on('data', () => {});
res.on('end', () => resolve());
res.on('error', reject);
});
req.on('error', reject);
req.end();
});
});

abortController.abort();
await target.finished;

// Event capture runs through the client's async processing queue, so
// drain it before reading the sink -- otherwise these assertions race.
await client.flush(5_000);

// The parent span proves tracing itself is live, so an absent
// http.client span is the option working rather than tracing being off.
assert(sentryTraceHeader, 'expected an injected sentry-trace header, so the client was instrumented');
const parent = transactions.find(t => t.transaction === 'parent');
assert(parent, `expected the 'parent' transaction, got: ${transactions.map(t => t.transaction).join(', ')}`);
const childOps = parent!.spans?.map(s => s.op) ?? [];
assertEquals(
childOps.includes('http.client'),
false,
`expected no http.client span, got ops: ${childOps.join(', ')}`,
);
Comment thread
cursor[bot] marked this conversation as resolved.
},
});

Deno.test({
name: 'denoHttpIntegration: node:http incoming request creates no http.server transaction when spans: false',
async fn() {
const transactions: TransactionEvent[] = [];
const client = initWithSpansDisabled(transactions);

// Captured inside the handler so we can tell "spans were disabled" apart
// from "the request was never instrumented at all".
let isolatedTransactionName: string | undefined;
const server = http.createServer((_req, res) => {
isolatedTransactionName = getIsolationScope().getScopeData().transactionName;
res.end('ok');
});
const port: number = await new Promise(resolve => {
server.listen(0, '127.0.0.1', () => {
resolve((server.address() as { port: number }).port);
});
});

const response = await fetch(`http://127.0.0.1:${port}/users/42`);
assertEquals(await response.text(), 'ok');
await new Promise<void>(resolve => server.close(() => resolve()));

// Drain the async processing queue first. Without this, "no http.server
// transaction yet" and "no http.server transaction at all" look alike,
// so the assertion below could pass while spans were still enabled.
await client.flush(5_000);

// Request isolation still runs with spans off, so this proves the
// instrumentation saw the request.
assertEquals(isolatedTransactionName, 'GET /users/42');
const ops = transactions.map(t => t.contexts?.trace?.op);
assertEquals(ops.includes('http.server'), false, `expected no http.server transaction, got ops: ${ops.join(', ')}`);
},
});
51 changes: 49 additions & 2 deletions packages/deno/test/deno-http.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
// <reference lib="deno.ns" />

import * as http from 'node:http';
import type { TransactionEvent } from '@sentry/core';
import { getMainCarrier } from '@sentry/core';
import type { Envelope, SessionAggregates, TransactionEvent } from '@sentry/core';
import { forEachEnvelopeItem, getMainCarrier } from '@sentry/core';
import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts';
import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts';
import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts';
import type { DenoClient } from '../build/esm/index.js';
import { init, startSpan } from '../build/esm/index.js';
import { makeTestTransport } from './transport.ts';

function resetGlobals(): void {
getMainCarrier().__SENTRY__ = undefined;
Expand Down Expand Up @@ -110,6 +111,52 @@ Deno.test({
},
});

Deno.test({
name: 'denoHttpIntegration: node:http incoming request records a release-health session by default',
async fn() {
resetGlobals();
const envelopes: Envelope[] = [];
const client = init({
dsn: 'https://username@domain/123',
release: '1.0.0',
transport: makeTestTransport(envelope => {
envelopes.push(envelope);
}),
});

const server = http.createServer((_req, res) => {
res.end('ok');
});
const port: number = await new Promise(resolve => {
server.listen(0, '127.0.0.1', () => {
resolve((server.address() as { port: number }).port);
});
});

const response = await fetch(`http://127.0.0.1:${port}/health`);
assertEquals(await response.text(), 'ok');
await new Promise<void>(resolve => server.close(() => resolve()));
await client.flush(2_000);

let sessionAggregates: SessionAggregates | undefined;
for (const envelope of envelopes) {
forEachEnvelopeItem(envelope, item => {
const [headers, body] = item;
if (headers.type === 'sessions') {
sessionAggregates = body as SessionAggregates;
}
});
}

assertExists(sessionAggregates);
assertEquals(sessionAggregates.attrs?.release, '1.0.0');
assertEquals(sessionAggregates.aggregates.length, 1);
assertEquals(sessionAggregates.aggregates[0]?.exited, 1);
assertEquals(sessionAggregates.aggregates[0]?.errored, 0);
assertEquals(sessionAggregates.aggregates[0]?.crashed, 0);
},
});

Deno.test({
name: 'denoHttpIntegration: node:http outgoing request creates a child http.client span',
async fn() {
Expand Down
Loading