Skip to content

Commit a8a4ef2

Browse files
committed
feat(stdlib): add Sentry support
See the docs on `compasWithSentry` for more details
1 parent 65186ed commit a8a4ef2

File tree

7 files changed

+229
-13
lines changed

7 files changed

+229
-13
lines changed

package-lock.json

Lines changed: 66 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/stdlib/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,5 @@ export {
9292
eventStop,
9393
newEventFromEvent,
9494
} from "./src/events.js";
95+
96+
export { _compasSentryExport, compasWithSentry } from "./src/sentry.js";

packages/stdlib/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
"lodash.merge": "4.6.2",
2626
"pino": "8.20.0"
2727
},
28+
"devDependencies": {
29+
"@sentry/node": "^7.110.0"
30+
},
2831
"author": {
2932
"name": "Dirk de Visser",
3033
"email": "npm@dirkdevisser.nl",

packages/stdlib/src/events.js

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AppError } from "./error.js";
22
import { isNil } from "./lodash.js";
3+
import { _compasSentryExport } from "./sentry.js";
34

45
/**
56
* @typedef {object} InsightEventSpan
@@ -13,8 +14,8 @@ import { isNil } from "./lodash.js";
1314

1415
/**
1516
* The insight event is a tool for tracking the duration of (async) functions manually.
16-
* By utilizing the insight event, you can gain access to a task or request-specific logger and
17-
* obtain insights into the execution time of your functions.
17+
* By utilizing the insight event, you can gain access to a task or request-specific
18+
* logger and obtain insights into the execution time of your functions.
1819
*
1920
* How to use the Insight Event:
2021
*
@@ -24,7 +25,8 @@ import { isNil } from "./lodash.js";
2425
* In your tests you can use {@link newTestEvent}.
2526
*
2627
* You could pass the event object down through your (async) functions as an argument.
27-
* This allows the insight event to associate the event with the specific task or request.
28+
* This allows the insight event to associate the event with the specific task or
29+
* request.
2830
*
2931
* Finally, you should stop the event for correct logging by calling {@link eventStop}.
3032
* When the root event is stopped via {@link eventStop} it calculates the duration
@@ -64,6 +66,7 @@ import { isNil } from "./lodash.js";
6466
* @property {InsightEvent} [rootEvent]
6567
* @property {string} [name]
6668
* @property {InsightEventSpan} span
69+
* @property {import("@sentry/node").Span} [_compasSentrySpan]
6770
*/
6871

6972
/**
@@ -91,6 +94,8 @@ function InsightEventConstructor(logger, signal) {
9194
abortedTime: undefined,
9295
children: [],
9396
},
97+
98+
_compasSentrySpan: undefined,
9499
};
95100
}
96101

@@ -120,6 +125,10 @@ export function newEventFromEvent(event) {
120125
if (event.signal?.aborted) {
121126
event.span.abortedTime = Date.now();
122127

128+
if (event._compasSentrySpan) {
129+
event._compasSentrySpan.end();
130+
}
131+
123132
throw AppError.serverError({
124133
message: "Operation aborted",
125134
span: getEventRoot(event).span,
@@ -151,9 +160,21 @@ export function eventStart(event, name) {
151160
event.span.name = name;
152161
event.span.startTime = Date.now();
153162

163+
if (typeof _compasSentryExport?.startInactiveSpan === "function") {
164+
event._compasSentrySpan = _compasSentryExport.startInactiveSpan({
165+
op: "event",
166+
name: name,
167+
description: name,
168+
});
169+
}
170+
154171
if (event.signal?.aborted) {
155172
event.span.abortedTime = Date.now();
156173

174+
if (event._compasSentrySpan) {
175+
event._compasSentrySpan.end();
176+
}
177+
157178
throw AppError.serverError({
158179
message: "Operation aborted",
159180
span: getEventRoot(event).span,
@@ -174,9 +195,18 @@ export function eventRename(event, name) {
174195
event.name = name;
175196
event.span.name = name;
176197

198+
if (event._compasSentrySpan) {
199+
event._compasSentrySpan.description = name;
200+
event._compasSentrySpan.updateName(name);
201+
}
202+
177203
if (event.signal?.aborted) {
178204
event.span.abortedTime = Date.now();
179205

206+
if (event._compasSentrySpan) {
207+
event._compasSentrySpan.end();
208+
}
209+
180210
throw AppError.serverError({
181211
message: "Operation aborted",
182212
span: getEventRoot(event).span,
@@ -199,6 +229,10 @@ export function eventStop(event) {
199229
event.span.duration = event.span.stopTime - event.span.startTime;
200230
}
201231

232+
if (event._compasSentrySpan) {
233+
event._compasSentrySpan.end();
234+
}
235+
202236
if (isNil(event.rootEvent)) {
203237
event.log.info({
204238
type: "event_span",

packages/stdlib/src/logger.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { environment, isProduction } from "./env.js";
33
import { AppError } from "./error.js";
44
import { isNil, isPlainObject, merge } from "./lodash.js";
55
import { loggerWriteGithubActions, loggerWritePretty } from "./log-writers.js";
6+
import { _compasSentryExport } from "./sentry.js";
67

78
/**
89
* @typedef {object} Logger
@@ -123,6 +124,63 @@ export function newLogger(options) {
123124
context,
124125
});
125126

127+
if (typeof _compasSentryExport?.addBreadcrumb === "function") {
128+
let addedContextAsBreadcrumb = false;
129+
130+
return {
131+
info: (message) => {
132+
if (!addedContextAsBreadcrumb) {
133+
// @ts-expect-error
134+
_compasSentryExport.addBreadcrumb({
135+
category: context.type,
136+
data: {
137+
...context,
138+
},
139+
level: "info",
140+
type: "default",
141+
});
142+
addedContextAsBreadcrumb = true;
143+
}
144+
145+
// @ts-expect-error
146+
_compasSentryExport.addBreadcrumb({
147+
category: context.type,
148+
data: typeof message === "string" ? undefined : message,
149+
message: typeof message === "string" ? message : undefined,
150+
level: "info",
151+
type: "default",
152+
});
153+
154+
childLogger.info({ message });
155+
},
156+
error: (message) => {
157+
if (!addedContextAsBreadcrumb) {
158+
// @ts-expect-error
159+
_compasSentryExport.addBreadcrumb({
160+
category: "log",
161+
data: {
162+
...context,
163+
},
164+
level: "info",
165+
type: "default",
166+
});
167+
addedContextAsBreadcrumb = true;
168+
}
169+
170+
// @ts-expect-error
171+
_compasSentryExport.addBreadcrumb({
172+
category: "log",
173+
data: typeof message === "string" ? undefined : message,
174+
message: typeof message === "string" ? message : undefined,
175+
level: "error",
176+
type: "error",
177+
});
178+
179+
childLogger.error({ message });
180+
},
181+
};
182+
}
183+
126184
return {
127185
info: (message) => childLogger.info({ message }),
128186
error: (message) => childLogger.error({ message }),

packages/stdlib/src/sentry.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* The Sentry version that all Compas packages use if set via {@link compasWithSentry}.
3+
*
4+
* @type {undefined|import("@sentry/node")}
5+
*/
6+
export let _compasSentryExport = undefined;
7+
8+
/**
9+
* Enable Sentry support. This comes with the following changes:
10+
*
11+
* Stdlib:
12+
* - Logger: both info and error logs are added as breadcrumbs to the current active span.
13+
* - Event: Events are propagated to Sentry as (inactive) spans.
14+
* Meaning that further logs are not necessarily correlated to the correct event.
15+
* This can be inferred based on the timeline.
16+
*
17+
* Server:
18+
* - Starts a new root span for each incoming request.
19+
* - Tries to name it based on the finalized name of `ctx.event`.
20+
* This is most likely in the format `router.foo.bar` for matched routes by the generated router.
21+
* - Uses the sentry-trace header when provided.
22+
* Note that if a custom list of `allowHeaders` is provided in the CORS options,
23+
* 'sentry-trace' and 'baggage' should be allowed as well.
24+
* - If the error handler retrieves an unknown or AppError.serverError, it is reported as an uncaught exception.
25+
* It is advised to set 'maxDepth' to '0' in your Sentry config, and to enable the 'extraErrorDataIntegration' integration.
26+
*
27+
* Queue:
28+
* - Starts a new root span for each handled Job
29+
* - Names it based on the job name.
30+
* - Reports unhandled errors as exceptions.
31+
*
32+
* All:
33+
* - Each package that has error logs, will report an exception as well.
34+
* - Note that we still execute the logs for now. Which may be removed in a future release.
35+
*
36+
* @param {import("@sentry/node")} instance
37+
*/
38+
export function compasWithSentry(instance) {
39+
_compasSentryExport = instance;
40+
}

packages/stdlib/src/utils.js

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import { AppError } from "./error.js";
99
import { isNil } from "./lodash.js";
1010
import {
1111
loggerDetermineDefaultDestination,
12-
newLogger,
1312
loggerExtendGlobalContext,
13+
newLogger,
1414
} from "./logger.js";
15+
import { _compasSentryExport } from "./sentry.js";
1516

1617
/**
1718
* Get the number of seconds since Unix epoch (1-1-1970).
@@ -97,30 +98,42 @@ export function mainFn(meta, cb) {
9798
process.exit(1);
9899
};
99100

100-
process.on("unhandledRejection", (reason, promise) =>
101+
process.on("unhandledRejection", (reason, promise) => {
102+
if (_compasSentryExport) {
103+
_compasSentryExport.captureException(reason);
104+
}
105+
101106
unhandled({
102107
type: "unhandledRejection",
103108
reason: AppError.format(reason),
104109
promise,
105-
}),
106-
);
110+
});
111+
});
112+
113+
process.on("uncaughtException", (error, origin) => {
114+
if (_compasSentryExport) {
115+
_compasSentryExport.captureException(error);
116+
}
107117

108-
process.on("uncaughtException", (error, origin) =>
109118
unhandled({
110119
type: "uncaughtException",
111120
error: AppError.format(error),
112121
origin,
113-
}),
114-
);
122+
});
123+
});
115124

116-
process.on("warning", (warn) =>
125+
process.on("warning", (warn) => {
117126
logger.error({
118127
type: "warning",
119128
warning: AppError.format(warn),
120-
}),
121-
);
129+
});
130+
});
122131

123132
Promise.resolve(cb(logger)).catch((e) => {
133+
if (_compasSentryExport) {
134+
_compasSentryExport.captureException(e);
135+
}
136+
124137
unhandled({
125138
type: "error",
126139
message: "Error caught from callback passed in `mainFn`",

0 commit comments

Comments
 (0)