Skip to content

Commit 2aef14c

Browse files
committed
feat(node): Request mode sessions
Captures request mode sessions associated with a specific release, and sends them to the Sentry as part of the Release Health functionality.
1 parent e5c7ef9 commit 2aef14c

28 files changed

+1210
-39
lines changed

packages/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export { addGlobalEventProcessor, getCurrentHub, getHubFromCarrier, Hub, makeMai
1717
export { API } from './api';
1818
export { BaseClient } from './baseclient';
1919
export { BackendClass, BaseBackend } from './basebackend';
20-
export { eventToSentryRequest, sessionToSentryRequest } from './request';
20+
export { eventToSentryRequest, sessionToSentryRequest, sessionAggregatesToSentryRequest } from './request';
2121
export { initAndBind, ClientClass } from './sdk';
2222
export { NoopTransport } from './transports/noop';
2323
export { SDK_VERSION } from './version';

packages/core/src/request.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Event, SdkInfo, SentryRequest, Session } from '@sentry/types';
1+
import { Event, SdkInfo, SentryRequest, SentryRequestType, Session, SessionAggregates } from '@sentry/types';
22

33
import { API } from './api';
44

@@ -45,6 +45,25 @@ export function sessionToSentryRequest(session: Session, api: API): SentryReques
4545
};
4646
}
4747

48+
/** Creates a SentryRequest from an Aggregates of Request mode sessions */
49+
export function sessionAggregatesToSentryRequest(sessionAggregates: SessionAggregates, api: API): SentryRequest {
50+
const sdkInfo = getSdkMetadataForEnvelopeHeader(api);
51+
const envelopeHeaders = JSON.stringify({
52+
sent_at: new Date().toISOString(),
53+
...(sdkInfo && { sdk: sdkInfo }),
54+
});
55+
// The server expects type `sessions` in headers for Session Aggregates payload
56+
const itemHeaders = JSON.stringify({
57+
type: 'sessions',
58+
});
59+
60+
return {
61+
body: `${envelopeHeaders}\n${itemHeaders}\n${JSON.stringify(sessionAggregates)}`,
62+
type: 'sessions' as SentryRequestType,
63+
url: api.getEnvelopeEndpointWithUrlEncodedAuth(),
64+
};
65+
}
66+
4867
/** Creates a SentryRequest from an event. */
4968
export function eventToSentryRequest(event: Event, api: API): SentryRequest {
5069
const sdkInfo = getSdkMetadataForEnvelopeHeader(api);

packages/core/test/lib/request.test.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import { DebugMeta, Event, SentryRequest, TransactionSamplingMethod } from '@sentry/types';
22

33
import { API } from '../../src/api';
4-
import { eventToSentryRequest } from '../../src/request';
4+
import { eventToSentryRequest, sessionAggregatesToSentryRequest } from '../../src/request';
5+
6+
const api = new API('https://[email protected]/12312012', {
7+
sdk: {
8+
integrations: ['AWSLambda'],
9+
name: 'sentry.javascript.browser',
10+
version: `12.31.12`,
11+
packages: [{ name: 'npm:@sentry/browser', version: `12.31.12` }],
12+
},
13+
});
514

615
describe('eventToSentryRequest', () => {
16+
let event: Event;
717
function parseEnvelopeRequest(request: SentryRequest): any {
818
const [envelopeHeaderString, itemHeaderString, eventString] = request.body.split('\n');
919

@@ -14,16 +24,6 @@ describe('eventToSentryRequest', () => {
1424
};
1525
}
1626

17-
const api = new API('https://[email protected]/12312012', {
18-
sdk: {
19-
integrations: ['AWSLambda'],
20-
name: 'sentry.javascript.browser',
21-
version: `12.31.12`,
22-
packages: [{ name: 'npm:@sentry/browser', version: `12.31.12` }],
23-
},
24-
});
25-
let event: Event;
26-
2727
beforeEach(() => {
2828
event = {
2929
contexts: { trace: { trace_id: '1231201211212012', span_id: '12261980', op: 'pageload' } },
@@ -125,3 +125,32 @@ describe('eventToSentryRequest', () => {
125125
);
126126
});
127127
});
128+
129+
describe('sessionAggregatesToSentryRequest', () => {
130+
it('test envelope creation for aggregateSessions', () => {
131+
const aggregatedSession = {
132+
attrs: { release: '1.0.x', environment: 'prod' },
133+
aggregates: [{ started: '2021-04-08T12:18:00.000Z', exited: 2 }],
134+
};
135+
const result = sessionAggregatesToSentryRequest(aggregatedSession, api);
136+
137+
const [envelopeHeaderString, itemHeaderString, sessionString] = result.body.split('\n');
138+
139+
expect(JSON.parse(envelopeHeaderString)).toEqual(
140+
expect.objectContaining({
141+
sdk: { name: 'sentry.javascript.browser', version: '12.31.12' },
142+
}),
143+
);
144+
expect(JSON.parse(itemHeaderString)).toEqual(
145+
expect.objectContaining({
146+
type: 'sessions',
147+
}),
148+
);
149+
expect(JSON.parse(sessionString)).toEqual(
150+
expect.objectContaining({
151+
attrs: { release: '1.0.x', environment: 'prod' },
152+
aggregates: [{ started: '2021-04-08T12:18:00.000Z', exited: 2 }],
153+
}),
154+
);
155+
});
156+
});

packages/hub/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// eslint-disable-next-line deprecation/deprecation
22
export { Carrier, DomainAsCarrier, Layer } from './interfaces';
33
export { addGlobalEventProcessor, Scope } from './scope';
4-
export { Session } from './session';
4+
export { Session, SessionFlusher } from './session';
55
export {
66
// eslint-disable-next-line deprecation/deprecation
77
getActiveDomain,

packages/hub/src/scope.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Extra,
1111
Extras,
1212
Primitive,
13+
RequestSessionStatus,
1314
Scope as ScopeInterface,
1415
ScopeContext,
1516
Severity,
@@ -65,6 +66,9 @@ export class Scope implements ScopeInterface {
6566
/** Session */
6667
protected _session?: Session;
6768

69+
/** Request Mode Session Status */
70+
protected _requestSession?: { status?: RequestSessionStatus };
71+
6872
/**
6973
* Inherit values from the parent scope.
7074
* @param scope to clone.
@@ -83,6 +87,9 @@ export class Scope implements ScopeInterface {
8387
newScope._transactionName = scope._transactionName;
8488
newScope._fingerprint = scope._fingerprint;
8589
newScope._eventProcessors = [...scope._eventProcessors];
90+
if (scope._requestSession) {
91+
newScope._requestSession = scope._requestSession;
92+
}
8693
}
8794
return newScope;
8895
}
@@ -297,6 +304,9 @@ export class Scope implements ScopeInterface {
297304
if (captureContext._fingerprint) {
298305
this._fingerprint = captureContext._fingerprint;
299306
}
307+
if (captureContext._requestSession) {
308+
this._requestSession = captureContext._requestSession;
309+
}
300310
} else if (isPlainObject(captureContext)) {
301311
// eslint-disable-next-line no-param-reassign
302312
captureContext = captureContext as ScopeContext;
@@ -312,6 +322,9 @@ export class Scope implements ScopeInterface {
312322
if (captureContext.fingerprint) {
313323
this._fingerprint = captureContext.fingerprint;
314324
}
325+
if (captureContext.requestSession) {
326+
this._requestSession = captureContext.requestSession;
327+
}
315328
}
316329

317330
return this;
@@ -329,6 +342,7 @@ export class Scope implements ScopeInterface {
329342
this._level = undefined;
330343
this._transactionName = undefined;
331344
this._fingerprint = undefined;
345+
this._requestSession = undefined;
332346
this._span = undefined;
333347
this._session = undefined;
334348
this._notifyScopeListeners();

packages/hub/src/session.ts

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1-
import { Session as SessionInterface, SessionContext, SessionStatus } from '@sentry/types';
2-
import { dropUndefinedKeys, uuid4 } from '@sentry/utils';
1+
import {
2+
AggregationCounts,
3+
RequestSessionStatus,
4+
Session as SessionInterface,
5+
SessionAggregates,
6+
SessionContext,
7+
SessionFlusher as SessionFlusherInterface,
8+
SessionStatus,
9+
Transport,
10+
} from '@sentry/types';
11+
import { dropUndefinedKeys, logger, uuid4 } from '@sentry/utils';
12+
13+
import { getCurrentHub } from './hub';
314

415
/**
516
* @inheritdoc
@@ -123,3 +134,120 @@ export class Session implements SessionInterface {
123134
});
124135
}
125136
}
137+
138+
type releaseHealthAttributes = {
139+
environment?: string;
140+
release: string;
141+
};
142+
143+
/**
144+
* @inheritdoc
145+
*/
146+
export class SessionFlusher implements SessionFlusherInterface {
147+
public readonly flushTimeout: number = 60;
148+
private _pendingAggregates: { [key: number]: AggregationCounts } = {};
149+
private _sessionAttrs: releaseHealthAttributes;
150+
private _intervalId: ReturnType<typeof setInterval>;
151+
private _isEnabled: boolean = true;
152+
private _transport: Transport;
153+
154+
constructor(transport: Transport, attrs: releaseHealthAttributes) {
155+
this._transport = transport;
156+
// Call to setInterval, so that flush is called every 60 seconds
157+
this._intervalId = setInterval(() => this.flush(), this.flushTimeout * 1000);
158+
this._sessionAttrs = attrs;
159+
}
160+
161+
/** Sends session aggregates to Transport */
162+
public sendSessionAggregates(sessionAggregates: SessionAggregates): void {
163+
if (!this._transport.sendSessionAggregates) {
164+
logger.warn("Dropping session because custom transport doesn't implement sendSessionAggregates");
165+
return;
166+
}
167+
this._transport.sendSessionAggregates(sessionAggregates).then(null, reason => {
168+
logger.error(`Error while sending session: ${reason}`);
169+
});
170+
}
171+
172+
/** Checks if `pendingAggregates` has entries, and if it does flushes them by calling `sendSessions` */
173+
flush(): void {
174+
const sessionAggregates = this.getSessionAggregates();
175+
if (sessionAggregates.aggregates.length === 0) {
176+
return;
177+
}
178+
this._pendingAggregates = {};
179+
this.sendSessionAggregates(sessionAggregates);
180+
}
181+
182+
/** Massages the entries in `pendingAggregates` and returns aggregated sessions */
183+
getSessionAggregates(): SessionAggregates {
184+
const aggregates: AggregationCounts[] = Object.keys(this._pendingAggregates).map((key: string) => {
185+
return this._pendingAggregates[parseInt(key)];
186+
});
187+
188+
const sessionAggregates: SessionAggregates = {
189+
attrs: this._sessionAttrs,
190+
aggregates: aggregates,
191+
};
192+
return dropUndefinedKeys(sessionAggregates);
193+
}
194+
195+
/** JSDoc */
196+
close(): void {
197+
clearInterval(this._intervalId);
198+
this._isEnabled = false;
199+
this.flush();
200+
}
201+
202+
/**
203+
* Wrapper function for _incrementSessionStatusCount that checks if the instance of SessionFlusher is enabled then
204+
* fetches the session status of the request from `_requestSession.status` on the scope and passes them to
205+
* `_incrementSessionStatusCount` along with the start date
206+
*/
207+
public incrementSessionStatusCount(): void {
208+
if (!this._isEnabled) {
209+
return;
210+
}
211+
const scope = getCurrentHub().getScope();
212+
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
213+
const requestSession = (scope as any)._requestSession;
214+
215+
if (requestSession && requestSession.status) {
216+
this._incrementSessionStatusCount(requestSession.status, new Date());
217+
// This is not entirely necessarily but is added as a safe guard to indicate the bounds of a request and so in
218+
// case captureRequestSession is called more than once to prevent double count
219+
(scope as any)._requestSession = undefined;
220+
221+
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
222+
}
223+
}
224+
225+
/**
226+
* Increments status bucket in pendingAggregates buffer (internal state) corresponding to status of
227+
* the session received
228+
*/
229+
private _incrementSessionStatusCount(status: RequestSessionStatus, date: Date): number {
230+
// Truncate minutes and seconds on Session Started attribute to have one minute bucket keys
231+
const sessionStartedTrunc: number = new Date(date).setSeconds(0, 0);
232+
this._pendingAggregates[sessionStartedTrunc] = this._pendingAggregates[sessionStartedTrunc] || {};
233+
234+
// corresponds to aggregated sessions in one specific minute bucket
235+
// for example, {"started":"2021-03-16T08:00:00.000Z","exited":4, "errored": 1}
236+
const aggregationCounts: AggregationCounts = this._pendingAggregates[sessionStartedTrunc];
237+
if (!aggregationCounts.started) {
238+
aggregationCounts.started = new Date(sessionStartedTrunc).toISOString();
239+
}
240+
241+
switch (status) {
242+
case RequestSessionStatus.Errored:
243+
aggregationCounts.errored = aggregationCounts.errored !== undefined ? aggregationCounts.errored + 1 : 1;
244+
return aggregationCounts.errored;
245+
case RequestSessionStatus.Ok:
246+
aggregationCounts.exited = aggregationCounts.exited !== undefined ? aggregationCounts.exited + 1 : 1;
247+
return aggregationCounts.exited;
248+
case RequestSessionStatus.Crashed:
249+
aggregationCounts.crashed = aggregationCounts.crashed !== undefined ? aggregationCounts.crashed + 1 : 1;
250+
return aggregationCounts.crashed;
251+
}
252+
}
253+
}

0 commit comments

Comments
 (0)