Skip to content

Commit cdb38a1

Browse files
isaacsclaude
andauthored
feat(deno): Record request sessions for Deno.serve
`denoHttpIntegration` records a request session for each `node:http` request, but `denoServeIntegration` recorded none. Most Deno apps use `Deno.serve`, so release health showed no request sessions and no crash-free rate for them. The shared helper `recordRequestSession` waits for the `close` event of a `node:http` response, which `Deno.serve` does not have. Core now exports `startRequestSession`, which starts the session and returns an `end()` function. `recordRequestSession` keeps its signature and calls `end()` from the `close` event, so Node does not change. The helper now defaults `sessionFlushingDelayMS` to 60 seconds. Before, only core's `node:http` subscription code set that default, and a caller that left the option out got a timer that fired at once. `denoServeIntegration` accepts `sessions` (default `true`) and `sessionFlushingDelayMS`, as `denoHttpIntegration` does. The session ends when the response finishes. For a streamed response, that is when the stream finishes. When the handler throws, the session ends after the SDK captures the error, so it counts as crashed. Sessions do not depend on tracing, so they are also recorded when the SDK skips the span. To match Node, OPTIONS and HEAD requests also record sessions. Fixes #25495 Fixes JS-3956 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent f7e6b65 commit cdb38a1

8 files changed

Lines changed: 390 additions & 19 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph
2727

2828
`ignoreStaticAssets` defaults to `true`, so `Deno.serve` no longer creates spans for `GET` requests to common static assets such as `favicon.ico`, `robots.txt` or `*.js` files. To keep these spans, set `denoServeIntegration({ ignoreStaticAssets: false })`. `Deno.serve` spans also follow the client's tracing configuration by default, so the integration does not start spans when tracing is disabled.
2929

30+
- **feat(deno): Record request sessions for `Deno.serve`**
31+
32+
`denoServeIntegration` now records a request session for each incoming request, as `denoHttpIntegration` does for `node:http`. This gives release health and crash-free rates for `Deno.serve` apps. Sessions are sent only when a `release` is set. To turn them off, set `denoServeIntegration({ sessions: false })`. `sessionFlushingDelayMS` sets how long the SDK waits before it sends the session aggregate.
33+
3034
## 11.8.0
3135

3236
### Important Changes

‎packages/core/src/integrations/http/record-request-session.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,29 @@ export function recordRequestSession(
3333
sessionFlushingDelayMS?: number;
3434
},
3535
): void {
36+
response.once('close', startRequestSession(client, { requestIsolationScope, sessionFlushingDelayMS }));
37+
}
38+
39+
/**
40+
* Starts a session and tracks it in the context of a given isolation scope.
41+
* Returns a function to call when the request ends. It puts the session into
42+
* a task, which aggregates it with other sessions in a certain time window
43+
* (sessionFlushingDelayMs, 60 seconds by default). Calls after the first
44+
* one do nothing.
45+
*
46+
* Use this when the server has no `node:http` response to pass to
47+
* {@link recordRequestSession}.
48+
*/
49+
export function startRequestSession(
50+
client: Client,
51+
{
52+
requestIsolationScope,
53+
sessionFlushingDelayMS = 60_000,
54+
}: {
55+
requestIsolationScope: Scope;
56+
sessionFlushingDelayMS?: number;
57+
},
58+
): () => void {
3659
// Errors in this request are counted by the request session aggregate, so they must not also
3760
// update a process session inherited from the parent isolation scope.
3861
requestIsolationScope.setSession();
@@ -41,7 +64,12 @@ export function recordRequestSession(
4164
});
4265
client.emit('startRequestSession');
4366

44-
response.once('close', () => {
67+
let ended = false;
68+
return () => {
69+
if (ended) {
70+
return;
71+
}
72+
ended = true;
4573
const requestSession = requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession;
4674

4775
if (client && requestSession) {
@@ -89,5 +117,5 @@ export function recordRequestSession(
89117
safeUnref(timeout);
90118
}
91119
}
92-
});
120+
};
93121
}

‎packages/core/src/server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export {
2222
DEFAULT_IGNORE_STATUS_CODES,
2323
processHttpServerTransactionEvent,
2424
} from './integrations/http/server-transaction-event';
25-
export { recordRequestSession } from './integrations/http/record-request-session';
25+
export { recordRequestSession, startRequestSession } from './integrations/http/record-request-session';
2626
export { getClientIPAddress } from './utils/clientIPAddress';
2727
export { addOutgoingRequestBreadcrumb } from './integrations/http/add-outgoing-request-breadcrumb';
2828
export {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { EventEmitter } from 'node:events';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { recordRequestSession, startRequestSession } from '../../../../src/integrations/http/record-request-session';
4+
import { Scope } from '../../../../src/scope';
5+
import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client';
6+
7+
function createTestClient(): TestClient {
8+
return new TestClient(getDefaultTestClientOptions({ release: '1.0.0' }));
9+
}
10+
11+
describe('startRequestSession()', () => {
12+
beforeEach(() => {
13+
vi.useFakeTimers();
14+
vi.setSystemTime(new Date('March 19, 1999 06:12:34 UTC'));
15+
});
16+
17+
afterEach(() => {
18+
vi.useRealTimers();
19+
});
20+
21+
it('counts the session when end() is called', () => {
22+
const client = createTestClient();
23+
const sendSessionSpy = vi.spyOn(client, 'sendSession');
24+
const requestIsolationScope = new Scope();
25+
26+
const end = startRequestSession(client, { requestIsolationScope });
27+
vi.runAllTimers();
28+
expect(sendSessionSpy).not.toHaveBeenCalled();
29+
30+
end();
31+
vi.runAllTimers();
32+
33+
expect(sendSessionSpy).toHaveBeenCalledWith({
34+
aggregates: [{ crashed: 0, errored: 0, exited: 1, started: '1999-03-19T06:12:00.000Z' }],
35+
});
36+
});
37+
38+
it('counts the status set on the request isolation scope before end()', () => {
39+
const client = createTestClient();
40+
const sendSessionSpy = vi.spyOn(client, 'sendSession');
41+
const requestIsolationScope = new Scope();
42+
43+
const end = startRequestSession(client, { requestIsolationScope });
44+
requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession!.status = 'crashed';
45+
end();
46+
vi.runAllTimers();
47+
48+
expect(sendSessionSpy).toHaveBeenCalledWith({
49+
aggregates: [{ crashed: 1, errored: 0, exited: 0, started: '1999-03-19T06:12:00.000Z' }],
50+
});
51+
});
52+
53+
it('counts a session once when end() is called more than once', () => {
54+
const client = createTestClient();
55+
const sendSessionSpy = vi.spyOn(client, 'sendSession');
56+
const requestIsolationScope = new Scope();
57+
58+
const end = startRequestSession(client, { requestIsolationScope });
59+
end();
60+
end();
61+
vi.runAllTimers();
62+
63+
expect(sendSessionSpy).toHaveBeenCalledTimes(1);
64+
expect(sendSessionSpy).toHaveBeenCalledWith({
65+
aggregates: [{ crashed: 0, errored: 0, exited: 1, started: '1999-03-19T06:12:00.000Z' }],
66+
});
67+
});
68+
69+
it('emits the "startRequestSession" hook when the session starts', () => {
70+
const client = createTestClient();
71+
const onStartRequestSession = vi.fn();
72+
client.on('startRequestSession', onStartRequestSession);
73+
74+
startRequestSession(client, { requestIsolationScope: new Scope() });
75+
76+
expect(onStartRequestSession).toHaveBeenCalledTimes(1);
77+
});
78+
79+
it('uses sessionFlushingDelayMS to schedule the aggregate', () => {
80+
const client = createTestClient();
81+
const sendSessionSpy = vi.spyOn(client, 'sendSession');
82+
83+
startRequestSession(client, { requestIsolationScope: new Scope(), sessionFlushingDelayMS: 1_000 })();
84+
85+
vi.advanceTimersByTime(999);
86+
expect(sendSessionSpy).not.toHaveBeenCalled();
87+
vi.advanceTimersByTime(1);
88+
expect(sendSessionSpy).toHaveBeenCalledTimes(1);
89+
});
90+
it('sends the aggregate after 60 seconds by default', () => {
91+
const client = createTestClient();
92+
const sendSessionSpy = vi.spyOn(client, 'sendSession');
93+
94+
startRequestSession(client, { requestIsolationScope: new Scope() })();
95+
96+
vi.advanceTimersByTime(59_999);
97+
expect(sendSessionSpy).not.toHaveBeenCalled();
98+
vi.advanceTimersByTime(1);
99+
expect(sendSessionSpy).toHaveBeenCalledTimes(1);
100+
});
101+
});
102+
103+
describe('recordRequestSession()', () => {
104+
beforeEach(() => {
105+
vi.useFakeTimers();
106+
vi.setSystemTime(new Date('March 19, 1999 06:12:34 UTC'));
107+
});
108+
109+
afterEach(() => {
110+
vi.useRealTimers();
111+
});
112+
113+
it('counts the session when the response emits close', () => {
114+
const client = createTestClient();
115+
const sendSessionSpy = vi.spyOn(client, 'sendSession');
116+
const response = Object.assign(new EventEmitter(), { statusCode: 200, headers: {} });
117+
118+
recordRequestSession(client, { requestIsolationScope: new Scope(), response });
119+
vi.runAllTimers();
120+
expect(sendSessionSpy).not.toHaveBeenCalled();
121+
122+
response.emit('close');
123+
vi.runAllTimers();
124+
125+
expect(sendSessionSpy).toHaveBeenCalledWith({
126+
aggregates: [{ crashed: 0, errored: 0, exited: 1, started: '1999-03-19T06:12:00.000Z' }],
127+
});
128+
});
129+
});

‎packages/deno/src/integrations/deno-serve.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ export type DenoServeIntegrationOptions = {
6161
*/
6262
onSpanEnd?: (span: Span, request: Request, response: Response) => void;
6363

64+
/**
65+
* Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for
66+
* incoming requests to track the health and crash-free rate of your releases in Sentry.
67+
*
68+
* For a streamed response, the session ends when the stream finishes.
69+
*
70+
* @default `true`
71+
*/
72+
sessions?: boolean;
73+
74+
/**
75+
* Number of milliseconds until sessions are flushed as a session aggregate.
76+
*
77+
* @default `60000` (60s)
78+
*/
79+
sessionFlushingDelayMS?: number;
80+
6481
/**
6582
* Do not capture spans for incoming `Deno.serve` requests with the given status codes.
6683
* By default, some 3xx and 4xx status codes are dropped (see @default).
@@ -149,6 +166,8 @@ const _denoServeIntegration = ((options: DenoServeIntegrationOptions = {}) => {
149166
spans: options.spans,
150167
onSpanCreated: options.onSpanCreated,
151168
onSpanEnd: options.onSpanEnd,
169+
sessions: options.sessions ?? true,
170+
sessionFlushingDelayMS: options.sessionFlushingDelayMS,
152171
processEvent(event: Event): Event | null {
153172
// Gated on this integration's own span origin so it does not filter `node:http`
154173
// transactions, which `denoHttpIntegration` owns via its own `ignoreStatusCodes`.

‎packages/deno/src/utils/streaming.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,17 @@ import type { Span } from '@sentry/core';
22
import { classifyResponseStreaming } from '@sentry/core/server';
33

44
/**
5-
* Tee a stream, and end the provided span when the stream ends.
5+
* Tee a stream, and end the provided span (if any) when the stream ends.
66
* Returns the other side of the tee, which can be used to send the
77
* response to a client.
88
*
99
* `onEnd` runs right before the span ends.
1010
*/
11-
export async function streamResponse(span: Span, res: Response, onEnd?: () => void): Promise<Response> {
11+
export async function streamResponse(span: Span | undefined, res: Response, onEnd?: () => void): Promise<Response> {
1212
const classification = classifyResponseStreaming(res);
1313
const endSpan = (): void => {
1414
onEnd?.();
15-
span.end();
15+
span?.end();
1616
};
1717

1818
// not streaming, just end the span and return the response

‎packages/deno/src/wrap-deno-request-handler.ts‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
winterCGRequestToRequestData,
2626
withIsolationScope,
2727
} from '@sentry/core';
28-
import { getClientIPAddress, isStaticAssetRequest } from '@sentry/core/server';
28+
import { getClientIPAddress, isStaticAssetRequest, startRequestSession } from '@sentry/core/server';
2929
import type { DenoServeIntegrationOptions } from './integrations/deno-serve';
3030
import { streamResponse } from './utils/streaming';
3131

@@ -95,17 +95,25 @@ export const wrapDenoRequestHandler = <Addr extends Deno.Addr = Deno.Addr>(
9595
}
9696
isolationScope.setClient(client);
9797

98+
const integration = client.getIntegrationByName<Integration & DenoServeIntegrationOptions>('DenoServe');
99+
const endSession = integration?.sessions
100+
? startRequestSession(client, {
101+
requestIsolationScope: isolationScope,
102+
sessionFlushingDelayMS: integration.sessionFlushingDelayMS,
103+
})
104+
: undefined;
105+
98106
if (request.method === 'OPTIONS' || request.method === 'HEAD') {
99107
try {
100108
return await handler();
101109
} catch (e) {
102110
captureServeError(e);
103111
throw e;
112+
} finally {
113+
endSession?.();
104114
}
105115
}
106116

107-
const integration = client.getIntegrationByName<Integration & DenoServeIntegrationOptions>('DenoServe');
108-
109117
const urlObject = parseStringToURLObject(request.url);
110118
const [rawName, attributes] = getHttpSpanDetailsFromUrlObject(
111119
urlObject,
@@ -174,16 +182,18 @@ export const wrapDenoRequestHandler = <Addr extends Deno.Addr = Deno.Addr>(
174182
},
175183
async () => {
176184
if (!createSpan) {
185+
let res: Response;
177186
try {
178-
const res = await handler();
187+
res = await handler();
179188
isolationScope.setContext('response', {
180189
status_code: res.status,
181190
});
182-
return res;
183191
} catch (e) {
184192
captureServeError(e);
193+
endSession?.();
185194
throw e;
186195
}
196+
return endSession ? streamResponse(undefined, res, endSession) : res;
187197
}
188198

189199
return startSpanManual({ name, attributes }, async span => {
@@ -205,14 +215,16 @@ export const wrapDenoRequestHandler = <Addr extends Deno.Addr = Deno.Addr>(
205215
} catch (e) {
206216
span.end();
207217
captureServeError(e);
218+
endSession?.();
208219
throw e;
209220
}
210221

211-
return streamResponse(
212-
span,
213-
res,
214-
onSpanEnd && (() => callHook('onSpanEnd', () => onSpanEnd(span, request, res))),
215-
);
222+
return streamResponse(span, res, () => {
223+
if (onSpanEnd) {
224+
callHook('onSpanEnd', () => onSpanEnd(span, request, res));
225+
}
226+
endSession?.();
227+
});
216228
});
217229
},
218230
);

0 commit comments

Comments
 (0)