Skip to content

Commit 849d1cf

Browse files
author
Luca Forstner
authored
feat(node): Send client reports (#12951)
1 parent 81e20c3 commit 849d1cf

File tree

14 files changed

+275
-33
lines changed

14 files changed

+275
-33
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
2+
import * as Sentry from '@sentry/node';
3+
4+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
5+
(async () => {
6+
Sentry.init({
7+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
8+
transport: loggingTransport,
9+
beforeSend(event) {
10+
return !event.type ? null : event;
11+
},
12+
});
13+
14+
Sentry.captureException(new Error('this should get dropped by the event processor'));
15+
16+
await Sentry.flush();
17+
18+
Sentry.captureException(new Error('this should get dropped by the event processor'));
19+
Sentry.captureException(new Error('this should get dropped by the event processor'));
20+
21+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
22+
Sentry.flush();
23+
})();
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
2+
3+
afterAll(() => {
4+
cleanupChildProcesses();
5+
});
6+
7+
test('should record client report for beforeSend', done => {
8+
createRunner(__dirname, 'scenario.ts')
9+
.expect({
10+
client_report: {
11+
discarded_events: [
12+
{
13+
category: 'error',
14+
quantity: 1,
15+
reason: 'before_send',
16+
},
17+
],
18+
},
19+
})
20+
.expect({
21+
client_report: {
22+
discarded_events: [
23+
{
24+
category: 'error',
25+
quantity: 2,
26+
reason: 'before_send',
27+
},
28+
],
29+
},
30+
})
31+
.start(done);
32+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
2+
import * as Sentry from '@sentry/node';
3+
4+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
5+
(async () => {
6+
Sentry.init({
7+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
8+
transport: loggingTransport,
9+
});
10+
11+
Sentry.addEventProcessor(event => {
12+
return !event.type ? null : event;
13+
});
14+
15+
Sentry.captureException(new Error('this should get dropped by the event processor'));
16+
17+
await Sentry.flush();
18+
19+
Sentry.captureException(new Error('this should get dropped by the event processor'));
20+
Sentry.captureException(new Error('this should get dropped by the event processor'));
21+
22+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
23+
Sentry.flush();
24+
})();
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
2+
3+
afterAll(() => {
4+
cleanupChildProcesses();
5+
});
6+
7+
test('should record client report for event processors', done => {
8+
createRunner(__dirname, 'scenario.ts')
9+
.expect({
10+
client_report: {
11+
discarded_events: [
12+
{
13+
category: 'error',
14+
quantity: 1,
15+
reason: 'event_processor',
16+
},
17+
],
18+
},
19+
})
20+
.expect({
21+
client_report: {
22+
discarded_events: [
23+
{
24+
category: 'error',
25+
quantity: 2,
26+
reason: 'event_processor',
27+
},
28+
],
29+
},
30+
})
31+
.start(done);
32+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
2+
import * as Sentry from '@sentry/node';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
transport: loggingTransport,
7+
clientReportFlushInterval: 5000,
8+
beforeSend(event) {
9+
return !event.type ? null : event;
10+
},
11+
});
12+
13+
Sentry.captureException(new Error('this should get dropped by before send'));
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
2+
3+
afterAll(() => {
4+
cleanupChildProcesses();
5+
});
6+
7+
test('should flush client reports automatically after the timeout interval', done => {
8+
createRunner(__dirname, 'scenario.ts')
9+
.expect({
10+
client_report: {
11+
discarded_events: [
12+
{
13+
category: 'error',
14+
quantity: 1,
15+
reason: 'before_send',
16+
},
17+
],
18+
},
19+
})
20+
.start(done);
21+
});

dev-packages/node-integration-tests/utils/runner.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { spawn, spawnSync } from 'child_process';
33
import { join } from 'path';
44
import { SDK_VERSION } from '@sentry/node';
55
import type {
6+
ClientReport,
67
Envelope,
78
EnvelopeItemType,
89
Event,
@@ -46,6 +47,12 @@ export function assertSentryCheckIn(actual: SerializedCheckIn, expected: Partial
4647
});
4748
}
4849

50+
export function assertSentryClientReport(actual: ClientReport, expected: Partial<ClientReport>): void {
51+
expect(actual).toMatchObject({
52+
...expected,
53+
});
54+
}
55+
4956
export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial<Envelope[0]>): void {
5057
expect(actual).toEqual({
5158
event_id: expect.any(String),
@@ -148,6 +155,9 @@ type Expected =
148155
}
149156
| {
150157
check_in: Partial<SerializedCheckIn> | ((event: SerializedCheckIn) => void);
158+
}
159+
| {
160+
client_report: Partial<ClientReport> | ((event: ClientReport) => void);
151161
};
152162

153163
type ExpectedEnvelopeHeader =
@@ -332,6 +342,17 @@ export function createRunner(...paths: string[]) {
332342

333343
expectCallbackCalled();
334344
}
345+
346+
if ('client_report' in expected) {
347+
const clientReport = item[1] as ClientReport;
348+
if (typeof expected.client_report === 'function') {
349+
expected.client_report(clientReport);
350+
} else {
351+
assertSentryClientReport(clientReport, expected.client_report);
352+
}
353+
354+
expectCallbackCalled();
355+
}
335356
} catch (e) {
336357
complete(e as Error);
337358
}

packages/browser/src/client.ts

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
SeverityLevel,
1313
UserFeedback,
1414
} from '@sentry/types';
15-
import { createClientReportEnvelope, dsnToString, getSDKSource, logger } from '@sentry/utils';
15+
import { getSDKSource, logger } from '@sentry/utils';
1616

1717
import { DEBUG_BUILD } from './debug-build';
1818
import { eventFromException, eventFromMessage } from './eventbuilder';
@@ -118,30 +118,4 @@ export class BrowserClient extends BaseClient<BrowserClientOptions> {
118118
event.platform = event.platform || 'javascript';
119119
return super._prepareEvent(event, hint, scope);
120120
}
121-
122-
/**
123-
* Sends client reports as an envelope.
124-
*/
125-
private _flushOutcomes(): void {
126-
const outcomes = this._clearOutcomes();
127-
128-
if (outcomes.length === 0) {
129-
DEBUG_BUILD && logger.log('No outcomes to send');
130-
return;
131-
}
132-
133-
// This is really the only place where we want to check for a DSN and only send outcomes then
134-
if (!this._dsn) {
135-
DEBUG_BUILD && logger.log('No dsn provided, will not send outcomes');
136-
return;
137-
}
138-
139-
DEBUG_BUILD && logger.log('Sending outcomes:', outcomes);
140-
141-
const envelope = createClientReportEnvelope(outcomes, this._options.tunnel && dsnToString(this._dsn));
142-
143-
// sendEnvelope should not throw
144-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
145-
this.sendEnvelope(envelope);
146-
}
147121
}

packages/core/src/baseclient.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ import {
3636
addItemToEnvelope,
3737
checkOrSetAlreadyCaught,
3838
createAttachmentEnvelopeItem,
39+
createClientReportEnvelope,
3940
dropUndefinedKeys,
41+
dsnToString,
4042
isParameterizedString,
4143
isPlainObject,
4244
isPrimitive,
@@ -871,6 +873,34 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
871873
});
872874
}
873875

876+
/**
877+
* Sends client reports as an envelope.
878+
*/
879+
protected _flushOutcomes(): void {
880+
DEBUG_BUILD && logger.log('Flushing outcomes...');
881+
882+
const outcomes = this._clearOutcomes();
883+
884+
if (outcomes.length === 0) {
885+
DEBUG_BUILD && logger.log('No outcomes to send');
886+
return;
887+
}
888+
889+
// This is really the only place where we want to check for a DSN and only send outcomes then
890+
if (!this._dsn) {
891+
DEBUG_BUILD && logger.log('No dsn provided, will not send outcomes');
892+
return;
893+
}
894+
895+
DEBUG_BUILD && logger.log('Sending outcomes:', outcomes);
896+
897+
const envelope = createClientReportEnvelope(outcomes, this._options.tunnel && dsnToString(this._dsn));
898+
899+
// sendEnvelope should not throw
900+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
901+
this.sendEnvelope(envelope);
902+
}
903+
874904
/**
875905
* @inheritDoc
876906
*/

packages/node/src/sdk/client.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@ import type { ServerRuntimeClientOptions } from '@sentry/core';
66
import { SDK_VERSION, ServerRuntimeClient, applySdkMetadata } from '@sentry/core';
77
import { logger } from '@sentry/utils';
88
import { isMainThread, threadId } from 'worker_threads';
9+
import { DEBUG_BUILD } from '../debug-build';
910
import type { NodeClientOptions } from '../types';
1011

12+
const DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS = 60_000; // 60s was chosen arbitrarily
13+
1114
/** A client for using Sentry with Node & OpenTelemetry. */
1215
export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
1316
public traceProvider: BasicTracerProvider | undefined;
1417
private _tracer: Tracer | undefined;
18+
private _clientReportInterval: NodeJS.Timeout | undefined;
19+
private _clientReportOnExitFlushListener: (() => void) | undefined;
1520

1621
public constructor(options: NodeClientOptions) {
1722
const clientOptions: ServerRuntimeClientOptions = {
@@ -44,9 +49,8 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
4449
return tracer;
4550
}
4651

47-
/**
48-
* @inheritDoc
49-
*/
52+
// Eslint ignore explanation: This is already documented in super.
53+
// eslint-disable-next-line jsdoc/require-jsdoc
5054
public async flush(timeout?: number): Promise<boolean> {
5155
const provider = this.traceProvider;
5256
const spanProcessor = provider?.activeSpanProcessor;
@@ -55,6 +59,60 @@ export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
5559
await spanProcessor.forceFlush();
5660
}
5761

62+
if (this.getOptions().sendClientReports) {
63+
this._flushOutcomes();
64+
}
65+
5866
return super.flush(timeout);
5967
}
68+
69+
// Eslint ignore explanation: This is already documented in super.
70+
// eslint-disable-next-line jsdoc/require-jsdoc
71+
public close(timeout?: number | undefined): PromiseLike<boolean> {
72+
if (this._clientReportInterval) {
73+
clearInterval(this._clientReportInterval);
74+
}
75+
76+
if (this._clientReportOnExitFlushListener) {
77+
process.off('beforeExit', this._clientReportOnExitFlushListener);
78+
}
79+
80+
return super.close(timeout);
81+
}
82+
83+
/**
84+
* Will start tracking client reports for this client.
85+
*
86+
* NOTICE: This method will create an interval that is periodically called and attach a `process.on('beforeExit')`
87+
* hook. To clean up these resources, call `.close()` when you no longer intend to use the client. Not doing so will
88+
* result in a memory leak.
89+
*/
90+
// The reason client reports need to be manually activated with this method instead of just enabling them in a
91+
// constructor, is that if users periodically and unboundedly create new clients, we will create more and more
92+
// intervals and beforeExit listeners, thus leaking memory. In these situations, users are required to call
93+
// `client.close()` in order to dispose of the acquired resources.
94+
// We assume that calling this method in Sentry.init() is a sensible default, because calling Sentry.init() over and
95+
// over again would also result in memory leaks.
96+
// Note: We have experimented with using `FinalizationRegisty` to clear the interval when the client is garbage
97+
// collected, but it did not work, because the cleanup function never got called.
98+
public startClientReportTracking(): void {
99+
const clientOptions = this.getOptions();
100+
if (clientOptions.sendClientReports) {
101+
this._clientReportOnExitFlushListener = () => {
102+
this._flushOutcomes();
103+
};
104+
105+
this._clientReportInterval = setInterval(
106+
() => {
107+
DEBUG_BUILD && logger.log('Flushing client reports based on interval.');
108+
this._flushOutcomes();
109+
},
110+
clientOptions.clientReportFlushInterval ?? DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS,
111+
)
112+
// Unref is critical for not preventing the process from exiting because the interval is active.
113+
.unref();
114+
115+
process.on('beforeExit', this._clientReportOnExitFlushListener);
116+
}
117+
}
60118
}

packages/node/src/sdk/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ function _init(
154154
startSessionTracking();
155155
}
156156

157+
client.startClientReportTracking();
158+
157159
updateScopeFromEnvVariables();
158160

159161
if (options.spotlight) {
@@ -228,6 +230,7 @@ function getClientOptions(
228230
transport: makeNodeTransport,
229231
dsn: process.env.SENTRY_DSN,
230232
environment: process.env.SENTRY_ENVIRONMENT,
233+
sendClientReports: true,
231234
});
232235

233236
const overwriteOptions = dropUndefinedKeys({

0 commit comments

Comments
 (0)