diff --git a/packages/browser/src/backend.ts b/packages/browser/src/backend.ts index 70dec5c9537b..4f3bade60e07 100644 --- a/packages/browser/src/backend.ts +++ b/packages/browser/src/backend.ts @@ -29,12 +29,6 @@ export interface BrowserOptions extends Options { /** @deprecated use {@link Options.denyUrls} instead. */ blacklistUrls?: Array; - - /** - * A flag enabling Sessions Tracking feature. - * By default, Sessions Tracking is enabled. - */ - autoSessionTracking?: boolean; } /** diff --git a/packages/node/package.json b/packages/node/package.json index 96d30f7f349e..ed82c3fd3524 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -56,11 +56,12 @@ "fix": "run-s fix:eslint fix:prettier", "fix:prettier": "prettier --write \"{src,test}/**/*.ts\"", "fix:eslint": "eslint . --format stylish --fix", - "test": "run-s test:jest test:express test:webpack", + "test": "run-s test:jest test:express test:webpack test:release-health", "test:jest": "jest", "test:watch": "jest --watch", "test:express": "node test/manual/express-scope-separation/start.js", "test:webpack": "cd test/manual/webpack-domain/ && yarn && node npm-build.js", + "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", "pack": "npm pack" }, "volta": { diff --git a/packages/node/src/sdk.ts b/packages/node/src/sdk.ts index 315141922a5e..67865c6ea317 100644 --- a/packages/node/src/sdk.ts +++ b/packages/node/src/sdk.ts @@ -1,5 +1,6 @@ import { getCurrentHub, initAndBind, Integrations as CoreIntegrations } from '@sentry/core'; import { getMainCarrier, setHubOnCarrier } from '@sentry/hub'; +import { SessionStatus } from '@sentry/types'; import { getGlobalObject } from '@sentry/utils'; import * as domain from 'domain'; @@ -96,9 +97,16 @@ export function init(options: NodeOptions = {}): void { const detectedRelease = getSentryRelease(); if (detectedRelease !== undefined) { options.release = detectedRelease; + } else { + // If release is not provided, then we should disable autoSessionTracking + options.autoSessionTracking = false; } } + if (options.autoSessionTracking === undefined) { + options.autoSessionTracking = true; + } + if (options.environment === undefined && process.env.SENTRY_ENVIRONMENT) { options.environment = process.env.SENTRY_ENVIRONMENT; } @@ -109,6 +117,10 @@ export function init(options: NodeOptions = {}): void { } initAndBind(NodeClient, options); + + if (options.autoSessionTracking) { + startSessionTracking(); + } } /** @@ -148,6 +160,20 @@ export async function close(timeout?: number): Promise { return Promise.reject(false); } +/** + * Function that takes an instance of NodeClient and checks if autoSessionTracking option is enabled for that client + */ +export function isAutoSessionTrackingEnabled(client?: NodeClient): boolean { + if (client === undefined) { + return false; + } + const clientOptions: NodeOptions = client && client.getOptions(); + if (clientOptions && clientOptions.autoSessionTracking !== undefined) { + return clientOptions.autoSessionTracking; + } + return false; +} + /** * Returns a release dynamically from environment variables. */ @@ -180,3 +206,24 @@ export function getSentryRelease(fallback?: string): string | undefined { fallback ); } + +/** + * Enable automatic Session Tracking for the node process. + */ +function startSessionTracking(): void { + const hub = getCurrentHub(); + hub.startSession(); + // Emitted in the case of healthy sessions, error of `mechanism.handled: true` and unhandledrejections because + // The 'beforeExit' event is not emitted for conditions causing explicit termination, + // such as calling process.exit() or uncaught exceptions. + // Ref: https://nodejs.org/api/process.html#process_event_beforeexit + process.on('beforeExit', () => { + const session = hub.getScope()?.getSession(); + const terminalStates = [SessionStatus.Exited, SessionStatus.Crashed]; + // Only call endSession, if the Session exists on Scope and SessionStatus is not a + // Terminal Status i.e. Exited or Crashed because + // "When a session is moved away from ok it must not be updated anymore." + // Ref: https://develop.sentry.dev/sdk/sessions/ + if (session && !terminalStates.includes(session.status)) hub.endSession(); + }); +} diff --git a/packages/node/src/transports/base.ts b/packages/node/src/transports/base.ts index 57e2be07d53d..e8522ef0304d 100644 --- a/packages/node/src/transports/base.ts +++ b/packages/node/src/transports/base.ts @@ -1,5 +1,5 @@ -import { API, eventToSentryRequest, SDK_VERSION } from '@sentry/core'; -import { DsnProtocol, Event, Response, Status, Transport, TransportOptions } from '@sentry/types'; +import { API, SDK_VERSION } from '@sentry/core'; +import { DsnProtocol, Event, Response, SentryRequest, Status, Transport, TransportOptions } from '@sentry/types'; import { logger, parseRetryAfterHeader, PromiseBuffer, SentryError } from '@sentry/utils'; import * as fs from 'fs'; import * as http from 'http'; @@ -124,7 +124,10 @@ export abstract class BaseTransport implements Transport { } /** JSDoc */ - protected async _sendWithModule(httpModule: HTTPModule, event: Event): Promise { + protected async _send(sentryReq: SentryRequest): Promise { + if (!this.module) { + throw new SentryError('No module available'); + } if (new Date(Date.now()) < this._disabledUntil) { return Promise.reject(new SentryError(`Transport locked till ${this._disabledUntil} due to too many requests.`)); } @@ -134,10 +137,11 @@ export abstract class BaseTransport implements Transport { } return this._buffer.add( new Promise((resolve, reject) => { - const sentryReq = eventToSentryRequest(event, this._api); + if (!this.module) { + throw new SentryError('No module available'); + } const options = this._getRequestOptions(new url.URL(sentryReq.url)); - - const req = httpModule.request(options, (res: http.IncomingMessage) => { + const req = this.module.request(options, (res: http.IncomingMessage) => { const statusCode = res.statusCode || 500; const status = Status.fromHttpCode(statusCode); diff --git a/packages/node/src/transports/http.ts b/packages/node/src/transports/http.ts index e707b75e6578..f7760cdeb827 100644 --- a/packages/node/src/transports/http.ts +++ b/packages/node/src/transports/http.ts @@ -1,5 +1,5 @@ -import { Event, Response, TransportOptions } from '@sentry/types'; -import { SentryError } from '@sentry/utils'; +import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core'; +import { Event, Response, Session, TransportOptions } from '@sentry/types'; import * as http from 'http'; import { BaseTransport } from './base'; @@ -20,9 +20,13 @@ export class HTTPTransport extends BaseTransport { * @inheritDoc */ public sendEvent(event: Event): Promise { - if (!this.module) { - throw new SentryError('No module available in HTTPTransport'); - } - return this._sendWithModule(this.module, event); + return this._send(eventToSentryRequest(event, this._api)); + } + + /** + * @inheritDoc + */ + public sendSession(session: Session): PromiseLike { + return this._send(sessionToSentryRequest(session, this._api)); } } diff --git a/packages/node/src/transports/https.ts b/packages/node/src/transports/https.ts index 78ee201b0faa..982bf73d3ae6 100644 --- a/packages/node/src/transports/https.ts +++ b/packages/node/src/transports/https.ts @@ -1,5 +1,5 @@ -import { Event, Response, TransportOptions } from '@sentry/types'; -import { SentryError } from '@sentry/utils'; +import { eventToSentryRequest, sessionToSentryRequest } from '@sentry/core'; +import { Event, Response, Session, TransportOptions } from '@sentry/types'; import * as https from 'https'; import { BaseTransport } from './base'; @@ -20,9 +20,13 @@ export class HTTPSTransport extends BaseTransport { * @inheritDoc */ public sendEvent(event: Event): Promise { - if (!this.module) { - throw new SentryError('No module available in HTTPSTransport'); - } - return this._sendWithModule(this.module, event); + return this._send(eventToSentryRequest(event, this._api)); + } + + /** + * @inheritDoc + */ + public sendSession(session: Session): PromiseLike { + return this._send(sessionToSentryRequest(session, this._api)); } } diff --git a/packages/node/test/manual/release-health/single-session/caught-exception-errored-session.js b/packages/node/test/manual/release-health/single-session/caught-exception-errored-session.js new file mode 100644 index 000000000000..7f2bf52a56fa --- /dev/null +++ b/packages/node/test/manual/release-health/single-session/caught-exception-errored-session.js @@ -0,0 +1,60 @@ +const Sentry = require('../../../../dist'); +const { assertSessions, constructStrippedSessionObject, BaseDummyTransport } = require('./test-utils'); + +let sessionCounter = 0; +process.on('exit', ()=> { + if (process.exitCode !== 1) { + console.log('SUCCESS: All application mode sessions were sent to node transport as expected'); + } +}) + +class DummyTransport extends BaseDummyTransport { + sendSession(session) { + sessionCounter++; + if (sessionCounter === 1) { + assertSessions(constructStrippedSessionObject(session), + { + init: true, + status: 'ok', + errors: 1, + release: '1.1' + } + ) + } + else if (sessionCounter === 2) { + assertSessions(constructStrippedSessionObject(session), + { + init: false, + status: 'exited', + errors: 1, + release: '1.1' + } + ) + } + else { + console.log('FAIL: Received way too many Sessions!'); + process.exit(1); + } + return super.sendSession(session); + } +} + +Sentry.init({ + dsn: 'http://test@example.com/1337', + release: '1.1', + transport: DummyTransport, +}); + +/** + * The following code snippet will capture exceptions of `mechanism.handled` equal to `true`, and so these sessions + * are treated as Errored Sessions. + * In this case, we have two session updates sent; First Session sent is due to the call to CaptureException that + * extracts event data and uses it to update the Session and sends it. The second session update is sent on the + * `beforeExit` event which happens right before the process exits. + */ +try { + throw new Error('hey there') +} +catch(e) { + Sentry.captureException(e); +} diff --git a/packages/node/test/manual/release-health/single-session/healthy-session.js b/packages/node/test/manual/release-health/single-session/healthy-session.js new file mode 100644 index 000000000000..c161963d97c4 --- /dev/null +++ b/packages/node/test/manual/release-health/single-session/healthy-session.js @@ -0,0 +1,41 @@ +const Sentry = require('../../../../dist'); +const { assertSessions, constructStrippedSessionObject, BaseDummyTransport } = require('./test-utils'); + +let sessionCounter = 0; +process.on('exit', ()=> { + if (process.exitCode !== 1) { + console.log('SUCCESS: All application mode sessions were sent to node transport as expected'); + } +}) + +class DummyTransport extends BaseDummyTransport { + sendSession(session) { + sessionCounter++; + if (sessionCounter === 1) { + assertSessions(constructStrippedSessionObject(session), + { + init: true, + status: 'exited', + errors: 0, + release: '1.1' + } + ) + } + else { + console.log('FAIL: Received way too many Sessions!'); + process.exit(1); + } + return super.sendSession(session); + } +} + +Sentry.init({ + dsn: 'http://test@example.com/1337', + release: '1.1', + transport: DummyTransport +}); + +/** + * This script or process, start a Session on init object, and calls endSession on `beforeExit` of the process, which + * sends a healthy session to the Server. + */ diff --git a/packages/node/test/manual/release-health/single-session/test-utils.js b/packages/node/test/manual/release-health/single-session/test-utils.js new file mode 100644 index 000000000000..7b0b93b59825 --- /dev/null +++ b/packages/node/test/manual/release-health/single-session/test-utils.js @@ -0,0 +1,29 @@ +function assertSessions(actual, expected) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + console.error('FAILED: Sessions do not match'); + process.exit(1); + } +} + +function constructStrippedSessionObject(actual) { + const { init, status, errors, release, did } = actual; + return { init, status, errors, release, did }; +} + +class BaseDummyTransport { + sendEvent(event) { + return Promise.resolve({ + status: 'success', + }); + } + sendSession(session) { + return Promise.resolve({ + status: 'success', + }); + } + close(timeout) { + return Promise.resolve(true); + } +} + +module.exports = { assertSessions, constructStrippedSessionObject, BaseDummyTransport }; diff --git a/packages/node/test/manual/release-health/single-session/uncaught-exception-crashed-session.js b/packages/node/test/manual/release-health/single-session/uncaught-exception-crashed-session.js new file mode 100644 index 000000000000..26d22b06a7fd --- /dev/null +++ b/packages/node/test/manual/release-health/single-session/uncaught-exception-crashed-session.js @@ -0,0 +1,36 @@ +const Sentry = require('../../../../dist'); +const { assertSessions, constructStrippedSessionObject, BaseDummyTransport } = require('./test-utils'); + +process.on('exit', ()=> { + if (process.exitCode !== 1) { + console.log('SUCCESS: All application mode sessions were sent to node transport as expected'); + } +}) + +class DummyTransport extends BaseDummyTransport { + sendSession(session) { + assertSessions(constructStrippedSessionObject(session), + { + init: true, + status: 'crashed', + errors: 1, + release: '1.1' + } + ) + process.exit(0); + } +} + +Sentry.init({ + dsn: 'http://test@example.com/1337', + release: '1.1', + transport: DummyTransport +}); +/** + * The following code snippet will throw an exception of `mechanism.handled` equal to `false`, and so this session + * is considered a Crashed Session. + * In this case, we have only session update that is sent, which is sent due to the call to CaptureException that + * extracts event data and uses it to update the Session and send it. No secondary session update in this case because + * we explicitly exit the process in the onUncaughtException handler and so the `beforeExit` event is not fired. + */ +throw new Error('test error') diff --git a/packages/node/test/manual/release-health/single-session/unhandled-rejection-crashed-session.js b/packages/node/test/manual/release-health/single-session/unhandled-rejection-crashed-session.js new file mode 100644 index 000000000000..1373b6321230 --- /dev/null +++ b/packages/node/test/manual/release-health/single-session/unhandled-rejection-crashed-session.js @@ -0,0 +1,51 @@ +const Sentry = require('../../../../dist'); +const { assertSessions, constructStrippedSessionObject, BaseDummyTransport } = require('./test-utils'); + +let sessionCounter = 0; +process.on('exit', () => { + if (process.exitCode !== 1) { + console.log('SUCCESS: All application mode sessions were sent to node transport as expected'); + } +}) + +class DummyTransport extends BaseDummyTransport { + sendSession(session) { + sessionCounter++; + + if (sessionCounter === 1) { + assertSessions(constructStrippedSessionObject(session), + { + init: true, + status: 'crashed', + errors: 1, + release: '1.1' + } + ) + } + else { + console.log('FAIL: Received way too many Sessions!'); + process.exit(1); + } + + return super.sendSession(session); + } +} + +Sentry.init({ + dsn: 'http://test@example.com/1337', + release: '1.1', + transport: DummyTransport +}); + +/** + * The following code snippet will throw an exception of `mechanism.handled` equal to `false`, and so this session + * is treated as a Crashed Session. + * In this case, we have two session updates sent; First Session sent is due to the call to CaptureException that + * extracts event data and uses it to update the Session and sends it. The second session update is sent on the + * `beforeExit` event which happens right before the process exits. + */ +new Promise(function(resolve, reject) { + reject(); +}).then(function() { + console.log('Promise Resolved'); +}); diff --git a/packages/node/test/transports/http.test.ts b/packages/node/test/transports/http.test.ts index f256a96d6505..5721d9498048 100644 --- a/packages/node/test/transports/http.test.ts +++ b/packages/node/test/transports/http.test.ts @@ -1,3 +1,4 @@ +import { Session } from '@sentry/hub'; import { TransportOptions } from '@sentry/types'; import { SentryError } from '@sentry/utils'; import * as http from 'http'; @@ -7,7 +8,8 @@ import { HTTPTransport } from '../../src/transports/http'; const mockSetEncoding = jest.fn(); const dsn = 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622'; -const transportPath = '/mysubpath/api/50622/store/'; +const storePath = '/mysubpath/api/50622/store/'; +const envelopePath = '/mysubpath/api/50622/envelope/'; let mockReturnCode = 200; let mockHeaders = {}; @@ -28,12 +30,12 @@ function createTransport(options: TransportOptions): HTTPTransport { return transport; } -function assertBasicOptions(options: any): void { +function assertBasicOptions(options: any, useEnvelope: boolean = false): void { expect(options.headers['X-Sentry-Auth']).toContain('sentry_version'); expect(options.headers['X-Sentry-Auth']).toContain('sentry_client'); expect(options.headers['X-Sentry-Auth']).toContain('sentry_key'); expect(options.port).toEqual('8989'); - expect(options.path).toEqual(transportPath); + expect(options.path).toEqual(useEnvelope ? envelopePath : storePath); expect(options.hostname).toEqual('sentry.io'); } @@ -70,6 +72,28 @@ describe('HTTPTransport', () => { } }); + test('send 200 session', async () => { + const transport = createTransport({ dsn }); + await transport.sendSession(new Session()); + + const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; + assertBasicOptions(requestOptions, true); + expect(mockSetEncoding).toHaveBeenCalled(); + }); + + test('send 400 session', async () => { + mockReturnCode = 400; + const transport = createTransport({ dsn }); + + try { + await transport.sendSession(new Session()); + } catch (e) { + const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; + assertBasicOptions(requestOptions, true); + expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); + } + }); + test('send x-sentry-error header', async () => { mockReturnCode = 429; mockHeaders = { diff --git a/packages/node/test/transports/https.test.ts b/packages/node/test/transports/https.test.ts index 369ef7ad78bc..bd413ff99536 100644 --- a/packages/node/test/transports/https.test.ts +++ b/packages/node/test/transports/https.test.ts @@ -1,3 +1,4 @@ +import { Session } from '@sentry/hub'; import { TransportOptions } from '@sentry/types'; import { SentryError } from '@sentry/utils'; import * as https from 'https'; @@ -7,7 +8,8 @@ import { HTTPSTransport } from '../../src/transports/https'; const mockSetEncoding = jest.fn(); const dsn = 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622'; -const transportPath = '/mysubpath/api/50622/store/'; +const storePath = '/mysubpath/api/50622/store/'; +const envelopePath = '/mysubpath/api/50622/envelope/'; let mockReturnCode = 200; let mockHeaders = {}; @@ -34,12 +36,12 @@ function createTransport(options: TransportOptions): HTTPSTransport { return transport; } -function assertBasicOptions(options: any): void { +function assertBasicOptions(options: any, useEnvelope: boolean = false): void { expect(options.headers['X-Sentry-Auth']).toContain('sentry_version'); expect(options.headers['X-Sentry-Auth']).toContain('sentry_client'); expect(options.headers['X-Sentry-Auth']).toContain('sentry_key'); expect(options.port).toEqual('8989'); - expect(options.path).toEqual(transportPath); + expect(options.path).toEqual(useEnvelope ? envelopePath : storePath); expect(options.hostname).toEqual('sentry.io'); } @@ -76,6 +78,28 @@ describe('HTTPSTransport', () => { } }); + test('send 200 session', async () => { + const transport = createTransport({ dsn }); + await transport.sendSession(new Session()); + + const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; + assertBasicOptions(requestOptions, true); + expect(mockSetEncoding).toHaveBeenCalled(); + }); + + test('send 400 session', async () => { + mockReturnCode = 400; + const transport = createTransport({ dsn }); + + try { + await transport.sendSession(new Session()); + } catch (e) { + const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0]; + assertBasicOptions(requestOptions, true); + expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`)); + } + }); + test('send x-sentry-error header', async () => { mockReturnCode = 429; mockHeaders = { diff --git a/packages/types/src/options.ts b/packages/types/src/options.ts index 413c39e81ac4..b850fff6da1c 100644 --- a/packages/types/src/options.ts +++ b/packages/types/src/options.ts @@ -118,6 +118,12 @@ export interface Options { */ tracesSampleRate?: number; + /** + * A flag enabling Sessions Tracking feature. + * By default, Sessions Tracking is enabled. + */ + autoSessionTracking?: boolean; + /** * Set of metadata about the SDK that can be internally used to enhance envelopes and events, * and provide additional data about every request.