Skip to content

Commit fe3c0cf

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 e5a1771 commit fe3c0cf

File tree

6 files changed

+161
-24
lines changed

6 files changed

+161
-24
lines changed

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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
const Sentry = require('../../../dist');
2+
3+
function assertSessions(actual, expected) {
4+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
5+
console.error('FAILED: Sessions do not match');
6+
process.exit(1);
7+
}
8+
}
9+
10+
function constructStrippedSessionObject(actual) {
11+
const { init, status, errors, release, did } = actual;
12+
return { init, status, errors, release, did };
13+
}
14+
15+
let remaining = 2;
16+
17+
class DummyTransport {
18+
sendEvent(event) {
19+
return Promise.resolve({
20+
status: 'success',
21+
});
22+
}
23+
sendSession(session) {
24+
if (remaining === 2) {
25+
assertSessions(constructStrippedSessionObject(session),
26+
{
27+
init: true,
28+
status: "ok",
29+
errors: 1,
30+
release: "1.1",
31+
did: "ahmed",
32+
}
33+
)
34+
}
35+
else {
36+
assertSessions(constructStrippedSessionObject(session),
37+
{
38+
init: false,
39+
status: "exited",
40+
errors: 1,
41+
release: "1.1",
42+
did: "ahmed",
43+
}
44+
)
45+
}
46+
47+
--remaining;
48+
49+
if (!remaining) {
50+
console.log('SUCCESS: All application mode sessions were sent to node transport as expected');
51+
}
52+
return Promise.resolve({
53+
status: 'success',
54+
});
55+
}
56+
close() {
57+
return Promise.resolve({
58+
status: 'success',
59+
});
60+
}
61+
}
62+
63+
Sentry.init({
64+
dsn: 'http://[email protected]/1337',
65+
release: '1.1',
66+
transport: DummyTransport,
67+
});
68+
69+
const hub = Sentry.getCurrentHub();
70+
71+
// Start the session
72+
hub.startSession({ user: { username: 'ahmed' } });
73+
// endSession should be called at the exit of a process (very end of all logic)
74+
process.on('exit', () => hub.endSession());
75+
76+
// Throw an error to cause Session to be an errored Session
77+
throw new Error('test error')

packages/node/test/transports/http.test.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Session } from '@sentry/hub';
12
import { TransportOptions } from '@sentry/types';
23
import { SentryError } from '@sentry/utils';
34
import * as http from 'http';
@@ -7,7 +8,8 @@ import { HTTPTransport } from '../../src/transports/http';
78

89
const mockSetEncoding = jest.fn();
910
const dsn = 'http://[email protected]:8989/mysubpath/50622';
10-
const transportPath = '/mysubpath/api/50622/store/';
11+
const storePath = '/mysubpath/api/50622/store/';
12+
const envelopePath = '/mysubpath/api/50622/envelope/';
1113
let mockReturnCode = 200;
1214
let mockHeaders = {};
1315

@@ -28,12 +30,12 @@ function createTransport(options: TransportOptions): HTTPTransport {
2830
return transport;
2931
}
3032

31-
function assertBasicOptions(options: any): void {
33+
function assertBasicOptions(options: any, useEnvelope: boolean = false): void {
3234
expect(options.headers['X-Sentry-Auth']).toContain('sentry_version');
3335
expect(options.headers['X-Sentry-Auth']).toContain('sentry_client');
3436
expect(options.headers['X-Sentry-Auth']).toContain('sentry_key');
3537
expect(options.port).toEqual('8989');
36-
expect(options.path).toEqual(transportPath);
38+
expect(options.path).toEqual(useEnvelope ? envelopePath : storePath);
3739
expect(options.hostname).toEqual('sentry.io');
3840
}
3941

@@ -70,6 +72,28 @@ describe('HTTPTransport', () => {
7072
}
7173
});
7274

75+
test('send 200 session', async () => {
76+
const transport = createTransport({ dsn });
77+
await transport.sendSession(new Session());
78+
79+
const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0];
80+
assertBasicOptions(requestOptions, true);
81+
expect(mockSetEncoding).toHaveBeenCalled();
82+
});
83+
84+
test('send 400 session', async () => {
85+
mockReturnCode = 400;
86+
const transport = createTransport({ dsn });
87+
88+
try {
89+
await transport.sendSession(new Session());
90+
} catch (e) {
91+
const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0];
92+
assertBasicOptions(requestOptions, true);
93+
expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`));
94+
}
95+
});
96+
7397
test('send x-sentry-error header', async () => {
7498
mockReturnCode = 429;
7599
mockHeaders = {

packages/node/test/transports/https.test.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Session } from '@sentry/hub';
12
import { TransportOptions } from '@sentry/types';
23
import { SentryError } from '@sentry/utils';
34
import * as https from 'https';
@@ -7,7 +8,8 @@ import { HTTPSTransport } from '../../src/transports/https';
78

89
const mockSetEncoding = jest.fn();
910
const dsn = 'https://[email protected]:8989/mysubpath/50622';
10-
const transportPath = '/mysubpath/api/50622/store/';
11+
const storePath = '/mysubpath/api/50622/store/';
12+
const envelopePath = '/mysubpath/api/50622/envelope/';
1113
let mockReturnCode = 200;
1214
let mockHeaders = {};
1315

@@ -34,12 +36,12 @@ function createTransport(options: TransportOptions): HTTPSTransport {
3436
return transport;
3537
}
3638

37-
function assertBasicOptions(options: any): void {
39+
function assertBasicOptions(options: any, useEnvelope: boolean = false): void {
3840
expect(options.headers['X-Sentry-Auth']).toContain('sentry_version');
3941
expect(options.headers['X-Sentry-Auth']).toContain('sentry_client');
4042
expect(options.headers['X-Sentry-Auth']).toContain('sentry_key');
4143
expect(options.port).toEqual('8989');
42-
expect(options.path).toEqual(transportPath);
44+
expect(options.path).toEqual(useEnvelope ? envelopePath : storePath);
4345
expect(options.hostname).toEqual('sentry.io');
4446
}
4547

@@ -76,6 +78,28 @@ describe('HTTPSTransport', () => {
7678
}
7779
});
7880

81+
test('send 200 session', async () => {
82+
const transport = createTransport({ dsn });
83+
await transport.sendSession(new Session());
84+
85+
const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0];
86+
assertBasicOptions(requestOptions, true);
87+
expect(mockSetEncoding).toHaveBeenCalled();
88+
});
89+
90+
test('send 400 session', async () => {
91+
mockReturnCode = 400;
92+
const transport = createTransport({ dsn });
93+
94+
try {
95+
await transport.sendSession(new Session());
96+
} catch (e) {
97+
const requestOptions = (transport.module!.request as jest.Mock).mock.calls[0][0];
98+
assertBasicOptions(requestOptions, true);
99+
expect(e).toEqual(new SentryError(`HTTP Error (${mockReturnCode})`));
100+
}
101+
});
102+
79103
test('send x-sentry-error header', async () => {
80104
mockReturnCode = 429;
81105
mockHeaders = {

0 commit comments

Comments
 (0)