Skip to content

Commit 9aacf37

Browse files
committed
feat(node): Application mode sessions
Add API to capture application mode sessions associated with a specific release, and send them to Sentry as part of the Release Health functionality.
1 parent 4bbb553 commit 9aacf37

File tree

14 files changed

+354
-31
lines changed

14 files changed

+354
-31
lines changed

packages/browser/src/backend.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,6 @@ export interface BrowserOptions extends Options {
2929

3030
/** @deprecated use {@link Options.denyUrls} instead. */
3131
blacklistUrls?: Array<string | RegExp>;
32-
33-
/**
34-
* A flag enabling Sessions Tracking feature.
35-
* By default, Sessions Tracking is enabled.
36-
*/
37-
autoSessionTracking?: boolean;
3832
}
3933

4034
/**

packages/node/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,12 @@
5656
"fix": "run-s fix:eslint fix:prettier",
5757
"fix:prettier": "prettier --write \"{src,test}/**/*.ts\"",
5858
"fix:eslint": "eslint . --format stylish --fix",
59-
"test": "run-s test:jest test:express test:webpack",
59+
"test": "run-s test:jest test:express test:webpack test:release-health",
6060
"test:jest": "jest",
6161
"test:watch": "jest --watch",
6262
"test:express": "node test/manual/express-scope-separation/start.js",
6363
"test:webpack": "cd test/manual/webpack-domain/ && yarn && node npm-build.js",
64+
"test:release-health": "node test/manual/release-health/single-session/healthy-session.js && node test/manual/release-health/single-session/caught-exception-errored-session.js && node test/manual/release-health/single-session/uncaught-exception-crashed-session.js && node test/manual/release-health/single-session/unhandled-rejection-crashed-session.js",
6465
"pack": "npm pack"
6566
},
6667
"volta": {

packages/node/src/sdk.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getCurrentHub, initAndBind, Integrations as CoreIntegrations } from '@sentry/core';
22
import { getMainCarrier, setHubOnCarrier } from '@sentry/hub';
3+
import { SessionStatus } from '@sentry/types';
34
import { getGlobalObject } from '@sentry/utils';
45
import * as domain from 'domain';
56

@@ -96,9 +97,16 @@ export function init(options: NodeOptions = {}): void {
9697
const detectedRelease = getSentryRelease();
9798
if (detectedRelease !== undefined) {
9899
options.release = detectedRelease;
100+
} else {
101+
// If release is not provided, then we should disable autoSessionTracking
102+
options.autoSessionTracking = false;
99103
}
100104
}
101105

106+
if (options.autoSessionTracking === undefined) {
107+
options.autoSessionTracking = true;
108+
}
109+
102110
if (options.environment === undefined && process.env.SENTRY_ENVIRONMENT) {
103111
options.environment = process.env.SENTRY_ENVIRONMENT;
104112
}
@@ -109,6 +117,10 @@ export function init(options: NodeOptions = {}): void {
109117
}
110118

111119
initAndBind(NodeClient, options);
120+
121+
if (options.autoSessionTracking) {
122+
startSessionTracking();
123+
}
112124
}
113125

114126
/**
@@ -148,6 +160,20 @@ export async function close(timeout?: number): Promise<boolean> {
148160
return Promise.reject(false);
149161
}
150162

163+
/**
164+
* Function that takes an instance of NodeClient and checks if autoSessionTracking option is enabled for that client
165+
*/
166+
export function isAutoSessionTrackingEnabled(client?: NodeClient): boolean {
167+
if (client === undefined) {
168+
return false;
169+
}
170+
const clientOptions: NodeOptions = client && client.getOptions();
171+
if (clientOptions && clientOptions.autoSessionTracking !== undefined) {
172+
return clientOptions.autoSessionTracking;
173+
}
174+
return false;
175+
}
176+
151177
/**
152178
* Returns a release dynamically from environment variables.
153179
*/
@@ -180,3 +206,22 @@ export function getSentryRelease(fallback?: string): string | undefined {
180206
fallback
181207
);
182208
}
209+
210+
/**
211+
* Enable automatic Session Tracking for the node process.
212+
*/
213+
function startSessionTracking(): void {
214+
const hub = getCurrentHub();
215+
hub.startSession();
216+
// Emitted in the case of healthy sessions, error of `mechanism.handled: true` and unhandledrejections because
217+
// The 'beforeExit' event is not emitted for conditions causing explicit termination,
218+
// such as calling process.exit() or uncaught exceptions.
219+
// Ref: https://nodejs.org/api/process.html#process_event_beforeexit
220+
process.on('beforeExit', () => {
221+
const session = hub.getScope()?.getSession();
222+
const terminalStates = [SessionStatus.Exited, SessionStatus.Crashed];
223+
// Only call endSession, if the Session exists on Scope and SessionStatus is not a
224+
// Terminal Status i.e. Exited or Crashed
225+
if (session && !terminalStates.includes(session.status)) hub.endSession();
226+
});
227+
}

packages/node/src/transports/base.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { API, eventToSentryRequest, SDK_VERSION } from '@sentry/core';
2-
import { DsnProtocol, Event, Response, Status, Transport, TransportOptions } from '@sentry/types';
1+
import { API, SDK_VERSION } from '@sentry/core';
2+
import { DsnProtocol, Event, Response, SentryRequest, Status, Transport, TransportOptions } from '@sentry/types';
33
import { logger, parseRetryAfterHeader, PromiseBuffer, SentryError } from '@sentry/utils';
44
import * as fs from 'fs';
55
import * as http from 'http';
@@ -124,7 +124,10 @@ export abstract class BaseTransport implements Transport {
124124
}
125125

126126
/** JSDoc */
127-
protected async _sendWithModule(httpModule: HTTPModule, event: Event): Promise<Response> {
127+
protected async _send(sentryReq: SentryRequest): Promise<Response> {
128+
if (!this.module) {
129+
throw new SentryError('No module available');
130+
}
128131
if (new Date(Date.now()) < this._disabledUntil) {
129132
return Promise.reject(new SentryError(`Transport locked till ${this._disabledUntil} due to too many requests.`));
130133
}
@@ -134,10 +137,11 @@ export abstract class BaseTransport implements Transport {
134137
}
135138
return this._buffer.add(
136139
new Promise<Response>((resolve, reject) => {
137-
const sentryReq = eventToSentryRequest(event, this._api);
140+
if (!this.module) {
141+
throw new SentryError('No module available');
142+
}
138143
const options = this._getRequestOptions(new url.URL(sentryReq.url));
139-
140-
const req = httpModule.request(options, (res: http.IncomingMessage) => {
144+
const req = this.module.request(options, (res: http.IncomingMessage) => {
141145
const statusCode = res.statusCode || 500;
142146
const status = Status.fromHttpCode(statusCode);
143147

packages/node/src/transports/http.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Event, Response, TransportOptions } from '@sentry/types';
2-
import { SentryError } from '@sentry/utils';
1+
import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core';
2+
import { Event, Response, Session, TransportOptions } from '@sentry/types';
33
import * as http from 'http';
44

55
import { BaseTransport } from './base';
@@ -20,9 +20,13 @@ export class HTTPTransport extends BaseTransport {
2020
* @inheritDoc
2121
*/
2222
public sendEvent(event: Event): Promise<Response> {
23-
if (!this.module) {
24-
throw new SentryError('No module available in HTTPTransport');
25-
}
26-
return this._sendWithModule(this.module, event);
23+
return this._send(eventToSentryRequest(event, this._api));
24+
}
25+
26+
/**
27+
* @inheritDoc
28+
*/
29+
public sendSession(session: Session): PromiseLike<Response> {
30+
return this._send(sessionToSentryRequest(session, this._api));
2731
}
2832
}

packages/node/src/transports/https.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Event, Response, TransportOptions } from '@sentry/types';
2-
import { SentryError } from '@sentry/utils';
1+
import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core';
2+
import { Event, Response, Session, TransportOptions } from '@sentry/types';
33
import * as https from 'https';
44

55
import { BaseTransport } from './base';
@@ -20,9 +20,13 @@ export class HTTPSTransport extends BaseTransport {
2020
* @inheritDoc
2121
*/
2222
public sendEvent(event: Event): Promise<Response> {
23-
if (!this.module) {
24-
throw new SentryError('No module available in HTTPSTransport');
25-
}
26-
return this._sendWithModule(this.module, event);
23+
return this._send(eventToSentryRequest(event, this._api));
24+
}
25+
26+
/**
27+
* @inheritDoc
28+
*/
29+
public sendSession(session: Session): PromiseLike<Response> {
30+
return this._send(sessionToSentryRequest(session, this._api));
2731
}
2832
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
const Sentry = require('../../../../dist');
2+
const { assertSessions, constructStrippedSessionObject, BaseDummyTransport } = require('./test-utils');
3+
4+
let sessionCounter = 0;
5+
process.on('exit', ()=> {
6+
if (process.exitCode !== 1) {
7+
console.log('SUCCESS: All application mode sessions were sent to node transport as expected');
8+
}
9+
})
10+
11+
class DummyTransport extends BaseDummyTransport {
12+
sendSession(session) {
13+
sessionCounter++;
14+
if (sessionCounter === 1) {
15+
assertSessions(constructStrippedSessionObject(session),
16+
{
17+
init: true,
18+
status: 'ok',
19+
errors: 1,
20+
release: '1.1'
21+
}
22+
)
23+
}
24+
else if (sessionCounter === 2) {
25+
assertSessions(constructStrippedSessionObject(session),
26+
{
27+
init: false,
28+
status: 'exited',
29+
errors: 1,
30+
release: '1.1'
31+
}
32+
)
33+
}
34+
else {
35+
console.log('FAIL: Received way too many Sessions!');
36+
process.exit(1);
37+
}
38+
return super.sendSession(session);
39+
}
40+
}
41+
42+
Sentry.init({
43+
dsn: 'http://[email protected]/1337',
44+
release: '1.1',
45+
transport: DummyTransport,
46+
});
47+
48+
/**
49+
* The following code snippet will capture exceptions of `mechanism.handled` equal to `true`, and so these sessions
50+
* are treated as Errored Sessions.
51+
* In this case, we have two session updates sent; First Session sent is due to the call to CaptureException that
52+
* extracts event data and uses it to update the Session and sends it. The second session update is sent on the
53+
* `beforeExit` event which happens right before the process exits.
54+
*/
55+
try {
56+
throw new Error('hey there')
57+
}
58+
catch(e) {
59+
Sentry.captureException(e);
60+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
const Sentry = require('../../../../dist');
2+
const { assertSessions, constructStrippedSessionObject, BaseDummyTransport } = require('./test-utils');
3+
4+
let sessionCounter = 0;
5+
process.on('exit', ()=> {
6+
if (process.exitCode !== 1) {
7+
console.log('SUCCESS: All application mode sessions were sent to node transport as expected');
8+
}
9+
})
10+
11+
class DummyTransport extends BaseDummyTransport {
12+
sendSession(session) {
13+
sessionCounter++;
14+
if (sessionCounter === 1) {
15+
assertSessions(constructStrippedSessionObject(session),
16+
{
17+
init: true,
18+
status: 'exited',
19+
errors: 0,
20+
release: '1.1'
21+
}
22+
)
23+
}
24+
else {
25+
console.log('FAIL: Received way too many Sessions!');
26+
process.exit(1);
27+
}
28+
return super.sendSession(session);
29+
}
30+
}
31+
32+
Sentry.init({
33+
dsn: 'http://[email protected]/1337',
34+
release: '1.1',
35+
transport: DummyTransport
36+
});
37+
38+
/**
39+
* This script or process, start a Session on init object, and calls endSession on `beforeExit` of the process, which
40+
* sends a healthy session to the Server.
41+
*/
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
function assertSessions(actual, expected) {
2+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
3+
console.error('FAILED: Sessions do not match');
4+
process.exit(1);
5+
}
6+
}
7+
8+
function constructStrippedSessionObject(actual) {
9+
const { init, status, errors, release, did } = actual;
10+
return { init, status, errors, release, did };
11+
}
12+
13+
class BaseDummyTransport {
14+
sendEvent(event) {
15+
return Promise.resolve({
16+
status: 'success',
17+
});
18+
}
19+
sendSession(session) {
20+
return Promise.resolve({
21+
status: 'success',
22+
});
23+
}
24+
close(timeout) {
25+
return Promise.resolve(true);
26+
}
27+
}
28+
29+
module.exports = { assertSessions, constructStrippedSessionObject, BaseDummyTransport };
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
const Sentry = require('../../../../dist');
2+
const { assertSessions, constructStrippedSessionObject, BaseDummyTransport } = require('./test-utils');
3+
4+
process.on('exit', ()=> {
5+
if (process.exitCode !== 1) {
6+
console.log('SUCCESS: All application mode sessions were sent to node transport as expected');
7+
}
8+
})
9+
10+
class DummyTransport extends BaseDummyTransport {
11+
sendSession(session) {
12+
assertSessions(constructStrippedSessionObject(session),
13+
{
14+
init: true,
15+
status: 'crashed',
16+
errors: 1,
17+
release: '1.1'
18+
}
19+
)
20+
process.exit(0);
21+
}
22+
}
23+
24+
Sentry.init({
25+
dsn: 'http://[email protected]/1337',
26+
release: '1.1',
27+
transport: DummyTransport
28+
});
29+
/**
30+
* The following code snippet will throw an exception of `mechanism.handled` equal to `false`, and so this session
31+
* is considered a Crashed Session.
32+
* In this case, we have only session update that is sent, which is sent due to the call to CaptureException that
33+
* extracts event data and uses it to update the Session and send it. No secondary session update in this case because
34+
* we explicitly exit the process in the onUncaughtException handler and so the `beforeExit` event is not fired.
35+
*/
36+
throw new Error('test error')

0 commit comments

Comments
 (0)