Skip to content

Commit 69acdc2

Browse files
authored
fix(core): truncate large error stacks and messages to prevent OOM (#3405)
## Summary Large error stacks and messages can OOM the worker process when serialized into OTel spans or `TaskRunError` objects. This was reported when throwing an error with a massive `.stack` property from a chat agent hook. This adds frame-based stack truncation (similar to Sentry's approach) plus message length limits, applied consistently across all error serialization paths. ### What changed **`packages/core/src/v3/errors.ts`** - `truncateStack()` — parses `error.stack` into message lines + frame lines, caps at 50 frames (keep top 5 closest to throw + bottom 45 entry points, with "... N frames omitted ..." in between). Individual lines capped at 1024 chars. - `truncateMessage()` — caps error messages at 1000 chars - Applied in `parseError()` and `sanitizeError()` **`packages/core/src/v3/otel/utils.ts`** - `sanitizeSpanError()` now uses `truncateStack` and `truncateMessage` from `errors.ts` instead of duplicating truncation logic - Non-Error values (strings, JSON) capped at 5000 chars **`packages/core/src/v3/tracer.ts`** - `startActiveSpan` catch block now delegates to `recordSpanException()` instead of calling `span.recordException()` directly ### Limits | What | Limit | Rationale | |------|-------|-----------| | Stack frames | 50 | Matches Sentry's `STACKTRACE_FRAME_LIMIT` | | Top frames kept | 5 | Closest to throw site | | Bottom frames kept | 45 | Entry points / framework frames | | Per-line length | 1024 | Matches Sentry, prevents regex DoS | | Message length | 1000 | Bounded but generous | | Generic string (non-Error) | 5000 | Fallback for JSON/string errors in spans | ## Test plan - [x] 17 unit tests in `packages/core/test/errors.test.ts` - [x] E2E: threw a 300-frame / 5000-char-message error in the ai-chat reference app, verified truncated stack and message in span via `get_span_details` - [x] Verified the run survived the error (no OOM, continued waiting for next message)
1 parent 45ba398 commit 69acdc2

File tree

6 files changed

+444
-21
lines changed

6 files changed

+444
-21
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Truncate large error stacks and messages to prevent OOM crashes. Stack traces are capped at 50 frames (keeping top 5 + bottom 45 with an omission notice), individual stack lines at 1024 chars, and error messages at 1000 chars. Applied in parseError, sanitizeError, and OTel span recording.

packages/core/src/v3/errors.ts

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -154,22 +154,74 @@ export function isCompleteTaskWithOutput(error: unknown): error is CompleteTaskW
154154
return error instanceof Error && error.name === "CompleteTaskWithOutput";
155155
}
156156

157+
const MAX_STACK_FRAMES = 50;
158+
const KEEP_TOP_FRAMES = 5;
159+
const MAX_STACK_LINE_LENGTH = 1024;
160+
const MAX_MESSAGE_LENGTH = 1_000;
161+
162+
/** Truncate a stack trace to at most MAX_STACK_FRAMES frames, keeping
163+
* the top (closest to throw) and bottom (entry points) frames.
164+
* Individual lines (including message lines) are capped at MAX_STACK_LINE_LENGTH
165+
* to prevent OOM from huge error messages embedded in the stack. */
166+
export function truncateStack(stack: string | undefined): string {
167+
if (!stack) return "";
168+
169+
const lines = stack.split("\n");
170+
171+
// First line(s) before the first frame are the error message
172+
const messageLines: string[] = [];
173+
const frameLines: string[] = [];
174+
175+
for (const line of lines) {
176+
const safe =
177+
line.length > MAX_STACK_LINE_LENGTH
178+
? line.slice(0, MAX_STACK_LINE_LENGTH) + "...[truncated]"
179+
: line;
180+
if (frameLines.length === 0 && !line.trimStart().startsWith("at ")) {
181+
messageLines.push(safe);
182+
} else {
183+
frameLines.push(safe);
184+
}
185+
}
186+
187+
if (frameLines.length <= MAX_STACK_FRAMES) {
188+
return [...messageLines, ...frameLines].join("\n");
189+
}
190+
191+
const keepBottom = MAX_STACK_FRAMES - KEEP_TOP_FRAMES;
192+
const omitted = frameLines.length - MAX_STACK_FRAMES;
193+
194+
return [
195+
...messageLines,
196+
...frameLines.slice(0, KEEP_TOP_FRAMES),
197+
` ... ${omitted} frames omitted ...`,
198+
...frameLines.slice(-keepBottom),
199+
].join("\n");
200+
}
201+
202+
export function truncateMessage(message: string | undefined): string {
203+
if (!message) return "";
204+
return message.length > MAX_MESSAGE_LENGTH
205+
? message.slice(0, MAX_MESSAGE_LENGTH) + "...[truncated]"
206+
: message;
207+
}
208+
157209
export function parseError(error: unknown): TaskRunError {
158210
if (isInternalError(error)) {
159211
return {
160212
type: "INTERNAL_ERROR",
161213
code: error.code,
162-
message: error.message,
163-
stackTrace: error.stack ?? "",
214+
message: truncateMessage(error.message),
215+
stackTrace: truncateStack(error.stack),
164216
};
165217
}
166218

167219
if (error instanceof Error) {
168220
return {
169221
type: "BUILT_IN_ERROR",
170222
name: error.name,
171-
message: error.message,
172-
stackTrace: error.stack ?? "",
223+
message: truncateMessage(error.message),
224+
stackTrace: truncateStack(error.stack),
173225
};
174226
}
175227

@@ -248,35 +300,52 @@ export function createJsonErrorObject(error: TaskRunError): SerializedError {
248300
}
249301
}
250302

251-
// Removes any null characters from the error message
303+
// Removes null characters and truncates oversized fields to prevent OOM
252304
export function sanitizeError(error: TaskRunError): TaskRunError {
253305
switch (error.type) {
254306
case "BUILT_IN_ERROR": {
255307
return {
256308
type: "BUILT_IN_ERROR",
257-
message: error.message?.replace(/\0/g, ""),
309+
message: truncateMessage(error.message?.replace(/\0/g, "")),
258310
name: error.name?.replace(/\0/g, ""),
259-
stackTrace: error.stackTrace?.replace(/\0/g, ""),
311+
stackTrace: truncateStack(error.stackTrace?.replace(/\0/g, "")),
260312
};
261313
}
262314
case "STRING_ERROR": {
263315
return {
264316
type: "STRING_ERROR",
265-
raw: error.raw.replace(/\0/g, ""),
317+
raw: truncateMessage(error.raw.replace(/\0/g, "")),
266318
};
267319
}
268320
case "CUSTOM_ERROR": {
321+
// CUSTOM_ERROR.raw holds JSON.stringify(error) which is later parsed by
322+
// JSON.parse in createErrorTaskError. Naive truncation would cut mid-token
323+
// and produce invalid JSON — wrap the preview in a valid JSON envelope.
324+
const clean = error.raw.replace(/\0/g, "");
325+
const safeRaw =
326+
clean.length > MAX_MESSAGE_LENGTH
327+
? JSON.stringify({ truncated: true, preview: clean.slice(0, MAX_MESSAGE_LENGTH) })
328+
: clean;
269329
return {
270330
type: "CUSTOM_ERROR",
271-
raw: error.raw.replace(/\0/g, ""),
331+
raw: safeRaw,
272332
};
273333
}
274334
case "INTERNAL_ERROR": {
335+
// message and stackTrace are optional for INTERNAL_ERROR — preserve
336+
// `undefined` so the `error.message ?? "Internal error (CODE)"` fallback
337+
// in createErrorTaskError still kicks in (empty string is not nullish).
275338
return {
276339
type: "INTERNAL_ERROR",
277340
code: error.code,
278-
message: error.message?.replace(/\0/g, ""),
279-
stackTrace: error.stackTrace?.replace(/\0/g, ""),
341+
message:
342+
error.message != null
343+
? truncateMessage(error.message.replace(/\0/g, ""))
344+
: undefined,
345+
stackTrace:
346+
error.stackTrace != null
347+
? truncateStack(error.stackTrace.replace(/\0/g, ""))
348+
: undefined,
280349
};
281350
}
282351
}

packages/core/src/v3/otel/utils.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,47 @@
11
import { type Span, SpanStatusCode, context, propagation } from "@opentelemetry/api";
2+
import { truncateStack, truncateMessage } from "../errors.js";
3+
4+
const MAX_GENERIC_LENGTH = 5_000;
5+
6+
function truncateGeneric(value: string): string {
7+
return value.length > MAX_GENERIC_LENGTH
8+
? value.slice(0, MAX_GENERIC_LENGTH) + "...[truncated]"
9+
: value;
10+
}
11+
12+
function serializeFallback(error: unknown): string {
13+
// JSON.stringify can throw (circular refs, BigInt) or return undefined
14+
// (symbol, undefined, function). Fall back to String() in both cases so we
15+
// never mask the original error being recorded.
16+
try {
17+
const json = JSON.stringify(error);
18+
if (json != null) return json;
19+
} catch {
20+
// fall through
21+
}
22+
try {
23+
return String(error);
24+
} catch {
25+
return "[unserializable error]";
26+
}
27+
}
228

329
export function recordSpanException(span: Span, error: unknown) {
430
if (error instanceof Error) {
531
span.recordException(sanitizeSpanError(error));
632
} else if (typeof error === "string") {
7-
span.recordException(error.replace(/\0/g, ""));
33+
span.recordException(truncateGeneric(error.replace(/\0/g, "")));
834
} else {
9-
span.recordException(JSON.stringify(error).replace(/\0/g, ""));
35+
span.recordException(truncateGeneric(serializeFallback(error).replace(/\0/g, "")));
1036
}
1137

1238
span.setStatus({ code: SpanStatusCode.ERROR });
1339
}
1440

1541
function sanitizeSpanError(error: Error) {
16-
// Create a new error object with the same name, message and stack trace
17-
const sanitizedError = new Error(error.message.replace(/\0/g, ""));
42+
const sanitizedError = new Error(truncateMessage(error.message.replace(/\0/g, "")));
1843
sanitizedError.name = error.name.replace(/\0/g, "");
19-
sanitizedError.stack = error.stack?.replace(/\0/g, "");
44+
sanitizedError.stack = truncateStack(error.stack?.replace(/\0/g, "")) || undefined;
2045

2146
return sanitizedError;
2247
}

packages/core/src/v3/tracer.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,7 @@ export class TriggerTracer {
145145
}
146146

147147
if (!spanEnded) {
148-
if (typeof e === "string" || e instanceof Error) {
149-
span.recordException(e);
150-
}
151-
152-
span.setStatus({ code: SpanStatusCode.ERROR });
148+
recordSpanException(span, e);
153149
}
154150

155151
throw e;

0 commit comments

Comments
 (0)