Skip to content

Commit b78ee24

Browse files
authored
Include unhandled exceptions in request log (#80)
* Include unhandled exceptions in request log * Get Sentry event ID
1 parent b7d9a48 commit b78ee24

File tree

8 files changed

+77
-46
lines changed

8 files changed

+77
-46
lines changed

src/common/requestLogger.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { IncomingHttpHeaders, OutgoingHttpHeaders } from "http";
66
import { tmpdir } from "os";
77
import { join } from "path";
88

9+
import { getSentryEventId } from "./sentry.js";
10+
import {
11+
truncateExceptionMessage,
12+
truncateExceptionStackTrace,
13+
} from "./serverErrorCounter.js";
914
import TempGzipFile from "./tempGzipFile.js";
1015

1116
const MAX_BODY_SIZE = 50_000; // 50 KB (uncompressed)
@@ -72,6 +77,7 @@ export type RequestLoggingConfig = {
7277
logRequestBody: boolean;
7378
logResponseHeaders: boolean;
7479
logResponseBody: boolean;
80+
logException: boolean;
7581
maskQueryParams: RegExp[];
7682
maskHeaders: RegExp[];
7783
maskRequestBodyCallback?: (request: Request) => Buffer | null | undefined;
@@ -90,6 +96,7 @@ const DEFAULT_CONFIG: RequestLoggingConfig = {
9096
logRequestBody: false,
9197
logResponseHeaders: true,
9298
logResponseBody: false,
99+
logException: true,
93100
maskQueryParams: [],
94101
maskHeaders: [],
95102
excludePaths: [],
@@ -168,7 +175,7 @@ export default class RequestLogger {
168175
return headers.map(([k, v]) => [k, this.shouldMaskHeader(k) ? MASKED : v]);
169176
}
170177

171-
logRequest(request: Request, response: Response) {
178+
logRequest(request: Request, response: Response, error?: Error) {
172179
if (!this.enabled || this.suspendUntil !== null) return;
173180

174181
const url = new URL(request.url);
@@ -248,6 +255,15 @@ export default class RequestLogger {
248255
uuid: randomUUID(),
249256
request: skipEmptyValues(request),
250257
response: skipEmptyValues(response),
258+
exception:
259+
error && this.config.logException
260+
? {
261+
type: error.name,
262+
message: truncateExceptionMessage(error.message),
263+
stacktrace: truncateExceptionStackTrace(error.stack || ""),
264+
sentryEventId: getSentryEventId(),
265+
}
266+
: null,
251267
};
252268
[item.request.body, item.response.body].forEach((body) => {
253269
if (body) {

src/common/sentry.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type * as Sentry from "@sentry/node";
2+
3+
let sentry: typeof Sentry | undefined;
4+
5+
// Initialize Sentry when the module is loaded
6+
(async () => {
7+
try {
8+
sentry = await import("@sentry/node");
9+
} catch (e) {
10+
// Sentry SDK is not installed, ignore
11+
}
12+
})();
13+
14+
/**
15+
* Returns the last Sentry event ID if available
16+
*/
17+
export function getSentryEventId(): string | undefined {
18+
if (sentry && sentry.lastEventId) {
19+
return sentry.lastEventId();
20+
}
21+
return undefined;
22+
}

src/common/serverErrorCounter.ts

Lines changed: 29 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import type * as Sentry from "@sentry/node";
21
import { createHash } from "crypto";
32

3+
import { getSentryEventId } from "./sentry.js";
44
import { ConsumerMethodPath, ServerError, ServerErrorsItem } from "./types.js";
55

66
const MAX_MSG_LENGTH = 2048;
@@ -10,13 +10,11 @@ export default class ServerErrorCounter {
1010
private errorCounts: Map<string, number>;
1111
private errorDetails: Map<string, ConsumerMethodPath & ServerError>;
1212
private sentryEventIds: Map<string, string>;
13-
private sentry: typeof Sentry | undefined;
1413

1514
constructor() {
1615
this.errorCounts = new Map();
1716
this.errorDetails = new Map();
1817
this.sentryEventIds = new Map();
19-
this.tryImportSentry();
2018
}
2119

2220
public addServerError(serverError: ConsumerMethodPath & ServerError) {
@@ -25,7 +23,11 @@ export default class ServerErrorCounter {
2523
this.errorDetails.set(key, serverError);
2624
}
2725
this.errorCounts.set(key, (this.errorCounts.get(key) || 0) + 1);
28-
this.captureSentryEventId(key);
26+
27+
const sentryEventId = getSentryEventId();
28+
if (sentryEventId) {
29+
this.sentryEventIds.set(key, sentryEventId);
30+
}
2931
}
3032

3133
public getAndResetServerErrors() {
@@ -38,8 +40,8 @@ export default class ServerErrorCounter {
3840
method: serverError.method,
3941
path: serverError.path,
4042
type: serverError.type,
41-
msg: this.getTruncatedMessage(serverError.msg),
42-
traceback: this.getTruncatedStack(serverError.traceback),
43+
msg: truncateExceptionMessage(serverError.msg),
44+
traceback: truncateExceptionStackTrace(serverError.traceback),
4345
sentry_event_id: this.sentryEventIds.get(key) || null,
4446
error_count: count,
4547
});
@@ -61,48 +63,30 @@ export default class ServerErrorCounter {
6163
].join("|");
6264
return createHash("md5").update(hashInput).digest("hex");
6365
}
66+
}
6467

65-
private getTruncatedMessage(msg: string) {
66-
msg = msg.trim();
67-
if (msg.length <= MAX_MSG_LENGTH) {
68-
return msg;
69-
}
70-
const suffix = "... (truncated)";
71-
const cutoff = MAX_MSG_LENGTH - suffix.length;
72-
return msg.substring(0, cutoff) + suffix;
73-
}
74-
75-
private getTruncatedStack(stack: string) {
76-
const suffix = "... (truncated) ...";
77-
const cutoff = MAX_STACKTRACE_LENGTH - suffix.length;
78-
const lines = stack.trim().split("\n");
79-
const truncatedLines: string[] = [];
80-
let length = 0;
81-
for (const line of lines) {
82-
if (length + line.length + 1 > cutoff) {
83-
truncatedLines.push(suffix);
84-
break;
85-
}
86-
truncatedLines.push(line);
87-
length += line.length + 1;
88-
}
89-
return truncatedLines.join("\n");
90-
}
91-
92-
private captureSentryEventId(serverErrorKey: string) {
93-
if (this.sentry && this.sentry.lastEventId) {
94-
const eventId = this.sentry.lastEventId();
95-
if (eventId) {
96-
this.sentryEventIds.set(serverErrorKey, eventId);
97-
}
98-
}
68+
export function truncateExceptionMessage(msg: string) {
69+
if (msg.length <= MAX_MSG_LENGTH) {
70+
return msg;
9971
}
72+
const suffix = "... (truncated)";
73+
const cutoff = MAX_MSG_LENGTH - suffix.length;
74+
return msg.substring(0, cutoff) + suffix;
75+
}
10076

101-
private async tryImportSentry() {
102-
try {
103-
this.sentry = await import("@sentry/node");
104-
} catch (e) {
105-
// Sentry SDK is not installed, ignore
77+
export function truncateExceptionStackTrace(stack: string) {
78+
const suffix = "... (truncated) ...";
79+
const cutoff = MAX_STACKTRACE_LENGTH - suffix.length;
80+
const lines = stack.trim().split("\n");
81+
const truncatedLines: string[] = [];
82+
let length = 0;
83+
for (const line of lines) {
84+
if (length + line.length + 1 > cutoff) {
85+
truncatedLines.push(suffix);
86+
break;
10687
}
88+
truncatedLines.push(line);
89+
length += line.length + 1;
10790
}
91+
return truncatedLines.join("\n");
10892
}

src/express/middleware.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ const getMiddleware = (app: Express | Router, client: ApitallyClient) => {
143143
size: Number(res.get("content-length")),
144144
body: convertBody(res.locals.body, res.get("content-type")),
145145
},
146+
res.locals.serverError,
146147
);
147148
}
148149
} catch (error) {

src/fastify/plugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ const apitallyPlugin: FastifyPluginAsync<ApitallyConfig> = async (
156156
reply.getHeader("content-type")?.toString(),
157157
),
158158
},
159+
reply.serverError,
159160
);
160161
}
161162
}

src/hono/middleware.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ const getMiddleware = (client: ApitallyClient): MiddlewareHandler => {
111111
size: responseSize,
112112
body: responseBody,
113113
},
114+
c.error,
114115
);
115116
}
116117
c.res = response;

src/koa/middleware.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ const getMiddleware = (client: ApitallyClient) => {
2323
}
2424
let path: string | undefined;
2525
let statusCode: number | undefined;
26+
let serverError: Error | undefined;
2627
const startTime = performance.now();
2728
try {
2829
await next();
2930
} catch (error: any) {
3031
path = getPath(ctx);
3132
statusCode = error.statusCode || error.status || 500;
3233
if (path && statusCode === 500 && error instanceof Error) {
34+
serverError = error;
3335
client.serverErrorCounter.addServerError({
3436
consumer: getConsumer(ctx)?.identifier,
3537
method: ctx.request.method,
@@ -90,6 +92,7 @@ const getMiddleware = (client: ApitallyClient) => {
9092
ctx.response.get("content-type"),
9193
),
9294
},
95+
serverError,
9396
);
9497
}
9598
}

tests/common/requestLogger.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ describe("Request logger", () => {
4545
size: 4,
4646
body: Buffer.from("test"),
4747
},
48+
new Error("test"),
4849
);
4950
}
5051

@@ -70,6 +71,8 @@ describe("Request logger", () => {
7071
expect(atob(items[0].request.body)).toBe("test");
7172
expect(items[0].response.statusCode).toBe(200);
7273
expect(atob(items[0].response.body)).toBe("test");
74+
expect(items[0].exception.type).toBe("Error");
75+
expect(items[0].exception.message).toBe("test");
7376
});
7477

7578
it("Log exclusions", async () => {

0 commit comments

Comments
 (0)