diff --git a/packages/deno/src/integrations/http.ts b/packages/deno/src/integrations/http.ts
index e12b03c1a9dd..e02fdf01a9fc 100644
--- a/packages/deno/src/integrations/http.ts
+++ b/packages/deno/src/integrations/http.ts
@@ -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,
@@ -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.
*
@@ -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 = {}) => {
@@ -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,
- maxRequestBodySize: options.maxRequestBodySize,
- ignoreRequestBody: options.ignoreRequestBody,
- onSpanCreated: options.onIncomingSpanCreated,
- onSpanEnd: options.onIncomingSpanEnd,
+ ...options,
errorMonitor,
- sessions: false,
});
subscribe(HTTP_ON_SERVER_REQUEST, onHttpServerRequest);
const { [HTTP_ON_CLIENT_REQUEST]: onHttpClientRequest } = getHttpClientSubscriptions({
- spans: options.spans,
+ ...options,
breadcrumbs,
propagateTrace: tracePropagation,
ignoreOutgoingRequests: options.ignoreOutgoingRequests
diff --git a/packages/deno/test/deno-http-sessions-disabled.test.ts b/packages/deno/test/deno-http-sessions-disabled.test.ts
new file mode 100644
index 000000000000..163ec8dde196
--- /dev/null
+++ b/packages/deno/test/deno-http-sessions-disabled.test.ts
@@ -0,0 +1,64 @@
+//
+
+/**
+ * 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(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(', ')}`);
+ },
+});
diff --git a/packages/deno/test/deno-http-spans-disabled.test.ts b/packages/deno/test/deno-http-spans-disabled.test.ts
new file mode 100644
index 000000000000..efd1f29755ff
--- /dev/null
+++ b/packages/deno/test/deno-http-spans-disabled.test.ts
@@ -0,0 +1,130 @@
+//
+
+/**
+ * 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((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(', ')}`,
+ );
+ },
+});
+
+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(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(', ')}`);
+ },
+});
diff --git a/packages/deno/test/deno-http.test.ts b/packages/deno/test/deno-http.test.ts
index 3c784576b019..0944304ac980 100644
--- a/packages/deno/test/deno-http.test.ts
+++ b/packages/deno/test/deno-http.test.ts
@@ -1,13 +1,14 @@
//
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;
@@ -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(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() {