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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @ph

`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.

- **feat(deno): Record request sessions for `Deno.serve`**

`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.

## 11.8.0

### Important Changes
Expand Down
32 changes: 30 additions & 2 deletions packages/core/src/integrations/http/record-request-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,29 @@ export function recordRequestSession(
sessionFlushingDelayMS?: number;
},
): void {
response.once('close', startRequestSession(client, { requestIsolationScope, sessionFlushingDelayMS }));
}

/**
* Starts a session and tracks it in the context of a given isolation scope.
* Returns a function to call when the request ends. It puts the session into
* a task, which aggregates it with other sessions in a certain time window
* (sessionFlushingDelayMs, 60 seconds by default). Calls after the first
* one do nothing.
*
* Use this when the server has no `node:http` response to pass to
* {@link recordRequestSession}.
*/
export function startRequestSession(
client: Client,
{
requestIsolationScope,
sessionFlushingDelayMS = 60_000,
}: {
requestIsolationScope: Scope;
sessionFlushingDelayMS?: number;
},
): () => void {
// Errors in this request are counted by the request session aggregate, so they must not also
// update a process session inherited from the parent isolation scope.
requestIsolationScope.setSession();
Expand All @@ -41,7 +64,12 @@ export function recordRequestSession(
});
client.emit('startRequestSession');

response.once('close', () => {
let ended = false;
return () => {
if (ended) {
return;
}
ended = true;
const requestSession = requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession;

if (client && requestSession) {
Expand Down Expand Up @@ -89,5 +117,5 @@ export function recordRequestSession(
safeUnref(timeout);
}
}
});
};
}
2 changes: 1 addition & 1 deletion packages/core/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export {
DEFAULT_IGNORE_STATUS_CODES,
processHttpServerTransactionEvent,
} from './integrations/http/server-transaction-event';
export { recordRequestSession } from './integrations/http/record-request-session';
export { recordRequestSession, startRequestSession } from './integrations/http/record-request-session';
export { getClientIPAddress } from './utils/clientIPAddress';
export { addOutgoingRequestBreadcrumb } from './integrations/http/add-outgoing-request-breadcrumb';
export {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { EventEmitter } from 'node:events';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { recordRequestSession, startRequestSession } from '../../../../src/integrations/http/record-request-session';
import { Scope } from '../../../../src/scope';
import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client';

function createTestClient(): TestClient {
return new TestClient(getDefaultTestClientOptions({ release: '1.0.0' }));
}

describe('startRequestSession()', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('March 19, 1999 06:12:34 UTC'));
});

afterEach(() => {
vi.useRealTimers();
});

it('counts the session when end() is called', () => {
const client = createTestClient();
const sendSessionSpy = vi.spyOn(client, 'sendSession');
const requestIsolationScope = new Scope();

const end = startRequestSession(client, { requestIsolationScope });
vi.runAllTimers();
expect(sendSessionSpy).not.toHaveBeenCalled();

end();
vi.runAllTimers();

expect(sendSessionSpy).toHaveBeenCalledWith({
aggregates: [{ crashed: 0, errored: 0, exited: 1, started: '1999-03-19T06:12:00.000Z' }],
});
});

it('counts the status set on the request isolation scope before end()', () => {
const client = createTestClient();
const sendSessionSpy = vi.spyOn(client, 'sendSession');
const requestIsolationScope = new Scope();

const end = startRequestSession(client, { requestIsolationScope });
requestIsolationScope.getScopeData().sdkProcessingMetadata.requestSession!.status = 'crashed';
end();
vi.runAllTimers();

expect(sendSessionSpy).toHaveBeenCalledWith({
aggregates: [{ crashed: 1, errored: 0, exited: 0, started: '1999-03-19T06:12:00.000Z' }],
});
});

it('counts a session once when end() is called more than once', () => {
const client = createTestClient();
const sendSessionSpy = vi.spyOn(client, 'sendSession');
const requestIsolationScope = new Scope();

const end = startRequestSession(client, { requestIsolationScope });
end();
end();
vi.runAllTimers();

expect(sendSessionSpy).toHaveBeenCalledTimes(1);
expect(sendSessionSpy).toHaveBeenCalledWith({
aggregates: [{ crashed: 0, errored: 0, exited: 1, started: '1999-03-19T06:12:00.000Z' }],
});
});

it('emits the "startRequestSession" hook when the session starts', () => {
const client = createTestClient();
const onStartRequestSession = vi.fn();
client.on('startRequestSession', onStartRequestSession);

startRequestSession(client, { requestIsolationScope: new Scope() });

expect(onStartRequestSession).toHaveBeenCalledTimes(1);
});

it('uses sessionFlushingDelayMS to schedule the aggregate', () => {
const client = createTestClient();
const sendSessionSpy = vi.spyOn(client, 'sendSession');

startRequestSession(client, { requestIsolationScope: new Scope(), sessionFlushingDelayMS: 1_000 })();

vi.advanceTimersByTime(999);
expect(sendSessionSpy).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(sendSessionSpy).toHaveBeenCalledTimes(1);
});
it('sends the aggregate after 60 seconds by default', () => {
const client = createTestClient();
const sendSessionSpy = vi.spyOn(client, 'sendSession');

startRequestSession(client, { requestIsolationScope: new Scope() })();

vi.advanceTimersByTime(59_999);
expect(sendSessionSpy).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(sendSessionSpy).toHaveBeenCalledTimes(1);
});
});

describe('recordRequestSession()', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('March 19, 1999 06:12:34 UTC'));
});

afterEach(() => {
vi.useRealTimers();
});

it('counts the session when the response emits close', () => {
const client = createTestClient();
const sendSessionSpy = vi.spyOn(client, 'sendSession');
const response = Object.assign(new EventEmitter(), { statusCode: 200, headers: {} });

recordRequestSession(client, { requestIsolationScope: new Scope(), response });
vi.runAllTimers();
expect(sendSessionSpy).not.toHaveBeenCalled();

response.emit('close');
vi.runAllTimers();

expect(sendSessionSpy).toHaveBeenCalledWith({
aggregates: [{ crashed: 0, errored: 0, exited: 1, started: '1999-03-19T06:12:00.000Z' }],
});
});
});
19 changes: 19 additions & 0 deletions packages/deno/src/integrations/deno-serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,23 @@ export type DenoServeIntegrationOptions = {
*/
onSpanEnd?: (span: Span, request: Request, response: Response) => void;

/**
* 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.
*
* For a streamed response, the session ends when the stream finishes.
*
* @default `true`
*/
sessions?: boolean;

/**
* Number of milliseconds until sessions are flushed as a session aggregate.
*
* @default `60000` (60s)
*/
sessionFlushingDelayMS?: number;
Comment thread
isaacs marked this conversation as resolved.

/**
* Do not capture spans for incoming `Deno.serve` requests with the given status codes.
* By default, some 3xx and 4xx status codes are dropped (see @default).
Expand Down Expand Up @@ -149,6 +166,8 @@ const _denoServeIntegration = ((options: DenoServeIntegrationOptions = {}) => {
spans: options.spans,
onSpanCreated: options.onSpanCreated,
onSpanEnd: options.onSpanEnd,
sessions: options.sessions ?? true,
sessionFlushingDelayMS: options.sessionFlushingDelayMS,
processEvent(event: Event): Event | null {
// Gated on this integration's own span origin so it does not filter `node:http`
// transactions, which `denoHttpIntegration` owns via its own `ignoreStatusCodes`.
Expand Down
6 changes: 3 additions & 3 deletions packages/deno/src/utils/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ import type { Span } from '@sentry/core';
import { classifyResponseStreaming } from '@sentry/core/server';

/**
* Tee a stream, and end the provided span when the stream ends.
* Tee a stream, and end the provided span (if any) when the stream ends.
* Returns the other side of the tee, which can be used to send the
* response to a client.
*
* `onEnd` runs right before the span ends.
*/
export async function streamResponse(span: Span, res: Response, onEnd?: () => void): Promise<Response> {
export async function streamResponse(span: Span | undefined, res: Response, onEnd?: () => void): Promise<Response> {
const classification = classifyResponseStreaming(res);
const endSpan = (): void => {
onEnd?.();
span.end();
span?.end();
};

// not streaming, just end the span and return the response
Expand Down
32 changes: 22 additions & 10 deletions packages/deno/src/wrap-deno-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
winterCGRequestToRequestData,
withIsolationScope,
} from '@sentry/core';
import { getClientIPAddress, isStaticAssetRequest } from '@sentry/core/server';
import { getClientIPAddress, isStaticAssetRequest, startRequestSession } from '@sentry/core/server';
import type { DenoServeIntegrationOptions } from './integrations/deno-serve';
import { streamResponse } from './utils/streaming';

Expand Down Expand Up @@ -100,17 +100,25 @@ export const wrapDenoRequestHandler = <Addr extends Deno.Addr = Deno.Addr>(
}
isolationScope.setClient(client);

const integration = client.getIntegrationByName<Integration & DenoServeIntegrationOptions>('DenoServe');
const endSession = integration?.sessions
? startRequestSession(client, {
requestIsolationScope: isolationScope,
sessionFlushingDelayMS: integration.sessionFlushingDelayMS,
})
: undefined;
Comment thread
isaacs marked this conversation as resolved.

if (request.method === 'OPTIONS' || request.method === 'HEAD') {
try {
return await handler();
} catch (e) {
captureServeError(e);
throw e;
} finally {
endSession?.();
}
}

const integration = client.getIntegrationByName<Integration & DenoServeIntegrationOptions>('DenoServe');

const urlObject = parseStringToURLObject(request.url);
const [rawName, attributes] = getHttpSpanDetailsFromUrlObject(
urlObject,
Expand Down Expand Up @@ -181,16 +189,18 @@ export const wrapDenoRequestHandler = <Addr extends Deno.Addr = Deno.Addr>(
},
async () => {
if (!createSpan) {
let res: Response;
try {
const res = await handler();
res = await handler();
isolationScope.setContext('response', {
status_code: res.status,
});
return res;
} catch (e) {
captureServeError(e);
endSession?.();
throw e;
}
return endSession ? streamResponse(undefined, res, endSession) : res;
}

return startSpanManual({ name, attributes }, async span => {
Expand All @@ -212,14 +222,16 @@ export const wrapDenoRequestHandler = <Addr extends Deno.Addr = Deno.Addr>(
} catch (e) {
span.end();
captureServeError(e);
endSession?.();
throw e;
}

return streamResponse(
span,
res,
onSpanEnd && (() => callUserCallback('onSpanEnd', () => onSpanEnd(span, request, res), undefined)),
);
return streamResponse(span, res, () => {
if (onSpanEnd) {
callUserCallback('onSpanEnd', () => onSpanEnd(span, request, res), undefined);
}
endSession?.();
});
});
},
);
Expand Down
Loading
Loading