Skip to content

Commit e59bb03

Browse files
ahmedetefyHazATkamilogorek
authored
feat(node): Release Health Session Aggregates (#3319)
* 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. * Apply suggestions from code review Co-authored-by: Kamil Ogórek <[email protected]> * ref: Only have sendSession * ref: RequestSession getter/setter on scope * fix: Tests getRequestSession * ref: initSessionFlusher * fix: Linter * fix: CR * Apply suggestions from code review Co-authored-by: Kamil Ogórek <[email protected]> * fix: Lints Co-authored-by: Daniel Griesser <[email protected]> Co-authored-by: Kamil Ogórek <[email protected]>
1 parent 60e1982 commit e59bb03

28 files changed

+1161
-46
lines changed

packages/core/src/request.ts

Lines changed: 6 additions & 4 deletions
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

@@ -28,19 +28,21 @@ function enhanceEventWithSdkInfo(event: Event, sdkInfo?: SdkInfo): Event {
2828
}
2929

3030
/** Creates a SentryRequest from a Session. */
31-
export function sessionToSentryRequest(session: Session, api: API): SentryRequest {
31+
export function sessionToSentryRequest(session: Session | SessionAggregates, api: API): SentryRequest {
3232
const sdkInfo = getSdkMetadataForEnvelopeHeader(api);
3333
const envelopeHeaders = JSON.stringify({
3434
sent_at: new Date().toISOString(),
3535
...(sdkInfo && { sdk: sdkInfo }),
3636
});
37+
// I know this is hacky but we don't want to add `session` to request type since it's never rate limited
38+
const type: SentryRequestType = 'aggregates' in session ? ('sessions' as SentryRequestType) : 'session';
3739
const itemHeaders = JSON.stringify({
38-
type: 'session',
40+
type,
3941
});
4042

4143
return {
4244
body: `${envelopeHeaders}\n${itemHeaders}\n${JSON.stringify(session)}`,
43-
type: 'session',
45+
type,
4446
url: api.getEnvelopeEndpointWithUrlEncodedAuth(),
4547
};
4648
}

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, sessionToSentryRequest } 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('sessionToSentryRequest', () => {
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 = sessionToSentryRequest(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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Extra,
1111
Extras,
1212
Primitive,
13+
RequestSession,
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?: RequestSession;
71+
6872
/**
6973
* Inherit values from the parent scope.
7074
* @param scope to clone.
@@ -83,6 +87,7 @@ export class Scope implements ScopeInterface {
8387
newScope._transactionName = scope._transactionName;
8488
newScope._fingerprint = scope._fingerprint;
8589
newScope._eventProcessors = [...scope._eventProcessors];
90+
newScope._requestSession = scope._requestSession;
8691
}
8792
return newScope;
8893
}
@@ -122,6 +127,21 @@ export class Scope implements ScopeInterface {
122127
return this._user;
123128
}
124129

130+
/**
131+
* @inheritDoc
132+
*/
133+
public getRequestSession(): RequestSession | undefined {
134+
return this._requestSession;
135+
}
136+
137+
/**
138+
* @inheritDoc
139+
*/
140+
public setRequestSession(requestSession?: RequestSession): this {
141+
this._requestSession = requestSession;
142+
return this;
143+
}
144+
125145
/**
126146
* @inheritDoc
127147
*/
@@ -297,6 +317,9 @@ export class Scope implements ScopeInterface {
297317
if (captureContext._fingerprint) {
298318
this._fingerprint = captureContext._fingerprint;
299319
}
320+
if (captureContext._requestSession) {
321+
this._requestSession = captureContext._requestSession;
322+
}
300323
} else if (isPlainObject(captureContext)) {
301324
// eslint-disable-next-line no-param-reassign
302325
captureContext = captureContext as ScopeContext;
@@ -312,6 +335,9 @@ export class Scope implements ScopeInterface {
312335
if (captureContext.fingerprint) {
313336
this._fingerprint = captureContext.fingerprint;
314337
}
338+
if (captureContext.requestSession) {
339+
this._requestSession = captureContext.requestSession;
340+
}
315341
}
316342

317343
return this;
@@ -329,6 +355,7 @@ export class Scope implements ScopeInterface {
329355
this._level = undefined;
330356
this._transactionName = undefined;
331357
this._fingerprint = undefined;
358+
this._requestSession = undefined;
332359
this._span = undefined;
333360
this._session = undefined;
334361
this._notifyScopeListeners();

packages/hub/src/session.ts

Lines changed: 129 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+
SessionFlusherLike,
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,119 @@ 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 SessionFlusherLike {
147+
public readonly flushTimeout: number = 60;
148+
private _pendingAggregates: Record<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.sendSession) {
164+
logger.warn("Dropping session because custom transport doesn't implement sendSession");
165+
return;
166+
}
167+
this._transport.sendSession(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+
public 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+
public 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,
191+
};
192+
return dropUndefinedKeys(sessionAggregates);
193+
}
194+
195+
/** JSDoc */
196+
public 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 `Scope.getRequestSession().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+
const requestSession = scope?.getRequestSession();
213+
214+
if (requestSession && requestSession.status) {
215+
this._incrementSessionStatusCount(requestSession.status, new Date());
216+
// This is not entirely necessarily but is added as a safe guard to indicate the bounds of a request and so in
217+
// case captureRequestSession is called more than once to prevent double count
218+
scope?.setRequestSession(undefined);
219+
220+
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
221+
}
222+
}
223+
224+
/**
225+
* Increments status bucket in pendingAggregates buffer (internal state) corresponding to status of
226+
* the session received
227+
*/
228+
private _incrementSessionStatusCount(status: RequestSessionStatus, date: Date): number {
229+
// Truncate minutes and seconds on Session Started attribute to have one minute bucket keys
230+
const sessionStartedTrunc = new Date(date).setSeconds(0, 0);
231+
this._pendingAggregates[sessionStartedTrunc] = this._pendingAggregates[sessionStartedTrunc] || {};
232+
233+
// corresponds to aggregated sessions in one specific minute bucket
234+
// for example, {"started":"2021-03-16T08:00:00.000Z","exited":4, "errored": 1}
235+
const aggregationCounts: AggregationCounts = this._pendingAggregates[sessionStartedTrunc];
236+
if (!aggregationCounts.started) {
237+
aggregationCounts.started = new Date(sessionStartedTrunc).toISOString();
238+
}
239+
240+
switch (status) {
241+
case RequestSessionStatus.Errored:
242+
aggregationCounts.errored = (aggregationCounts.errored || 0) + 1;
243+
return aggregationCounts.errored;
244+
case RequestSessionStatus.Ok:
245+
aggregationCounts.exited = (aggregationCounts.exited || 0) + 1;
246+
return aggregationCounts.exited;
247+
case RequestSessionStatus.Crashed:
248+
aggregationCounts.crashed = (aggregationCounts.crashed || 0) + 1;
249+
return aggregationCounts.crashed;
250+
}
251+
}
252+
}

0 commit comments

Comments
 (0)