Skip to content

Commit 4f36f61

Browse files
matt-aitkenTrigger.dev RepoOps
authored andcommitted
feat(core,sdk,cli,webapp,run-engine): store and show thrown error causes
## Summary Throwing an error that wraps another one lost the inner error entirely: ```ts throw new Error("Could not sync the customer", { cause: originalError }); ``` Only the outermost name, message and stack were ever serialized, so `originalError` never reached the dashboard, the CLI, alerts, or the API. The cause chain is now flattened onto the run error and shown everywhere the error itself is shown. ## Where you see it | Surface | How it appears | |---|---| | Run page, error panel | A "Caused by" block per cause under the main error, each with its own name, message and stack | | Trace view, span exception | Appended to the exception message and stacktrace as `Caused by: ...` | | `dev` CLI | Appended to the printed stack as `Caused by: ...`, with full frames | | Failure alerts: email | A "Caused by" line plus a stack code block per cause | | Failure alerts: Slack | Appended to the error code block | | Failure alerts: webhook | `causes` array on the `TaskRunError` payload | | REST API (`retrieve run`) | `causes` array on `run.error` | | Realtime run records | `causes` array on `run.error` | | MCP run tools | A `Caused by:` line per cause under the error | | SDK, `triggerAndWait` and `triggerAndSubscribe` | Rebuilt as a native `err.cause` chain | Not included: the Errors page groups by fingerprint and reads flat `error_type` / `error_message` / `sample_stack_trace` columns, so it keeps showing the outermost error only. Surfacing causes there needs a schema change and is left for separate work. Fingerprints are computed from type, message and stack and do not read `causes`, so existing error groups are unaffected by this change. ## Design `TaskRunBuiltInError` and `SerializedError` gain an optional `causes` array, flattened outermost first rather than nested. A cause chain is linear, so an array models it without needing a recursive zod schema threaded through every exhaustive switch. Causes are arbitrary user data, so the walk is defensive throughout. It is bounded at five links and cycle guarded, and every read is wrapped: `instanceof`, a property get, `JSON.stringify` and `String` can each throw on a hostile or revoked `Proxy`, and `parseError` sits on the universal error path. Losing a cause there is acceptable; throwing would replace the user's real error with a crash. ```ts function readErrorCause(value: unknown): unknown { try { return value instanceof Error ? (value as { cause?: unknown }).cause : undefined; } catch (_e) { return undefined; } } ``` Cause stacks get a tighter frame budget than the error that was actually thrown (10 frames vs 50), and the run engine's truncation net re-bounds them, so the worst-case stored payload stays bounded rather than multiplying by the chain length. Closes #1603 Mono-RevId: 02396fcda2dcedbabaddfa63f90f91acab6f3672
1 parent ae86de2 commit 4f36f61

16 files changed

Lines changed: 1031 additions & 98 deletions

File tree

‎.changeset/error-cause-chains.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
"trigger.dev": patch
5+
---
6+
7+
Thrown error `cause` chains are now captured and shown. When a task throws an error that wraps another one, the run's error in the dashboard, the CLI dev output, and failure alerts all carry the chain instead of only the outermost message.
8+
9+
```ts
10+
throw new Error("Could not sync the customer", { cause: originalError });
11+
```
12+
13+
The chain is flattened outermost first, capped at five causes, and cycle safe. It also rides on the `error` of API and realtime run records as a `causes` array, and `triggerAndWait` and `triggerAndSubscribe` rebuild it as a native `cause` on the error they hand back, so `err.cause` works in your own catch blocks.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { EnvelopeIcon } from "@heroicons/react/20/solid";
2+
import {
3+
type TaskRunError,
4+
type TaskRunErrorCause,
5+
taskRunErrorEnhancer,
6+
} from "@trigger.dev/core/v3";
7+
import { Button } from "~/components/primitives/Buttons";
8+
import { Callout } from "~/components/primitives/Callout";
9+
import { CodeBlock } from "~/components/code/CodeBlock";
10+
import { Header3 } from "~/components/primitives/Headers";
11+
import { Paragraph } from "~/components/primitives/Paragraph";
12+
import { Feedback } from "~/components/Feedback";
13+
14+
type EnhancedRunError = ReturnType<typeof taskRunErrorEnhancer>;
15+
16+
const panelClassName = "flex flex-col gap-2";
17+
const messageClassName =
18+
"text-wrap font-sans text-sm font-normal text-rose-500 dark:text-rose-200 [word-break:break-word]";
19+
20+
/** Every error gets a title. Only BUILT_IN_ERROR and INTERNAL_ERROR carry one of
21+
* their own, and either can be empty, so everything else falls back to "Error". */
22+
function errorTitle(error: EnhancedRunError): string {
23+
switch (error.type) {
24+
case "BUILT_IN_ERROR":
25+
return error.name || "Error";
26+
case "INTERNAL_ERROR":
27+
return error.code || "Error";
28+
case "STRING_ERROR":
29+
case "CUSTOM_ERROR":
30+
return "Error";
31+
}
32+
}
33+
34+
export function RunError({ error }: { error: TaskRunError }) {
35+
const enhancedError = taskRunErrorEnhancer(error);
36+
37+
return (
38+
<div className={panelClassName}>
39+
<Header3 className="text-rose-500">{errorTitle(enhancedError)}</Header3>
40+
<RunErrorBody error={enhancedError} />
41+
</div>
42+
);
43+
}
44+
45+
function RunErrorBody({ error: enhancedError }: { error: EnhancedRunError }) {
46+
switch (enhancedError.type) {
47+
case "STRING_ERROR":
48+
return <Callout variant="error">{enhancedError.raw}</Callout>;
49+
case "CUSTOM_ERROR": {
50+
return (
51+
<CodeBlock
52+
showCopyButton={false}
53+
showLineNumbers={false}
54+
code={enhancedError.raw}
55+
maxLines={20}
56+
/>
57+
);
58+
}
59+
case "BUILT_IN_ERROR":
60+
case "INTERNAL_ERROR": {
61+
return (
62+
<>
63+
{enhancedError.message && (
64+
<Callout variant="error">
65+
<pre className={messageClassName}>{enhancedError.message}</pre>
66+
</Callout>
67+
)}
68+
{enhancedError.link &&
69+
(enhancedError.link.magic === "CONTACT_FORM" ? (
70+
<Feedback
71+
button={
72+
<Button
73+
variant="tertiary/medium"
74+
LeadingIcon={EnvelopeIcon}
75+
leadingIconClassName="text-blue-400"
76+
fullWidth
77+
textAlignLeft
78+
>
79+
{enhancedError.link.name}
80+
</Button>
81+
}
82+
/>
83+
) : (
84+
<Callout variant="docs" to={enhancedError.link.href}>
85+
{enhancedError.link.name}
86+
</Callout>
87+
))}
88+
{enhancedError.stackTrace && (
89+
<CodeBlock
90+
showCopyButton={false}
91+
showLineNumbers={false}
92+
code={enhancedError.stackTrace}
93+
maxLines={20}
94+
/>
95+
)}
96+
{"causes" in enhancedError && enhancedError.causes?.length ? (
97+
<RunErrorCauses causes={enhancedError.causes} />
98+
) : null}
99+
</>
100+
);
101+
}
102+
}
103+
}
104+
105+
function RunErrorCauses({ causes }: { causes: TaskRunErrorCause[] }) {
106+
const visible = causes.filter((cause) => cause.name || cause.message || cause.stackTrace);
107+
108+
if (visible.length === 0) {
109+
return null;
110+
}
111+
112+
return (
113+
<div className="flex flex-col gap-2 border-l border-rose-500/30 pl-3">
114+
{visible.map((cause, index) => (
115+
<div key={index} className="flex flex-col gap-2">
116+
<Paragraph variant="small" className="text-rose-500">
117+
Caused by{cause.name ? `: ${cause.name}` : null}
118+
</Paragraph>
119+
{cause.message && <pre className={messageClassName}>{cause.message}</pre>}
120+
{cause.stackTrace && (
121+
<CodeBlock
122+
showCopyButton={false}
123+
showLineNumbers={false}
124+
code={cause.stackTrace}
125+
maxLines={12}
126+
/>
127+
)}
128+
</div>
129+
))}
130+
</div>
131+
);
132+
}

‎apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx‎

Lines changed: 2 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,13 @@ import {
77
ClipboardDocumentIcon,
88
ClockIcon,
99
CloudArrowDownIcon,
10-
EnvelopeIcon,
1110
ExclamationTriangleIcon,
1211
KeyIcon,
1312
QueueListIcon,
1413
SignalIcon,
1514
} from "@heroicons/react/20/solid";
1615
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
17-
import {
18-
formatDurationMilliseconds,
19-
type TaskRunError,
20-
taskRunErrorEnhancer,
21-
} from "@trigger.dev/core/v3";
16+
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
2217
import { assertNever } from "assert-never";
2318
import { type ReactNode, useEffect } from "react";
2419
import { typedjson, useTypedFetcher } from "remix-typedjson";
@@ -27,8 +22,8 @@ import { ExitIcon } from "~/assets/icons/ExitIcon";
2722
import { QueuesIcon } from "~/assets/icons/QueuesIcon";
2823
import { AdminDebugRun } from "~/components/admin/debugRun";
2924
import { CodeBlock } from "~/components/code/CodeBlock";
25+
import { RunError } from "~/components/runs/v3/RunError";
3026
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
31-
import { Feedback } from "~/components/Feedback";
3227
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
3328
import { MachineTooltipInfo } from "~/components/MachineTooltipInfo";
3429
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -1561,75 +1556,6 @@ function TraceExportMenuItems({ runParam }: { runParam: string }) {
15611556
);
15621557
}
15631558

1564-
function RunError({ error }: { error: TaskRunError }) {
1565-
const enhancedError = taskRunErrorEnhancer(error);
1566-
1567-
switch (enhancedError.type) {
1568-
case "STRING_ERROR":
1569-
return (
1570-
<div className="flex flex-col gap-2 rounded-sm border border-rose-500/50 px-3 pb-3 pt-2">
1571-
<Header3 className="text-rose-500">Error</Header3>
1572-
<Callout variant="error">{enhancedError.raw}</Callout>
1573-
</div>
1574-
);
1575-
case "CUSTOM_ERROR": {
1576-
return (
1577-
<div className="flex flex-col gap-2 rounded-sm border border-rose-500/50 px-3 pb-3 pt-2">
1578-
<CodeBlock
1579-
showCopyButton={false}
1580-
showLineNumbers={false}
1581-
code={enhancedError.raw}
1582-
maxLines={20}
1583-
/>
1584-
</div>
1585-
);
1586-
}
1587-
case "BUILT_IN_ERROR":
1588-
case "INTERNAL_ERROR": {
1589-
const name = "name" in enhancedError ? enhancedError.name : enhancedError.code;
1590-
return (
1591-
<div className="flex flex-col gap-2 rounded-sm border border-rose-500/50 px-3 pb-3 pt-2">
1592-
<Header3 className="text-rose-500">{name}</Header3>
1593-
{enhancedError.message && (
1594-
<Callout variant="error">
1595-
<pre className="text-wrap font-sans text-sm font-normal text-rose-500 dark:text-rose-200 [word-break:break-word]">
1596-
{enhancedError.message}
1597-
</pre>
1598-
</Callout>
1599-
)}
1600-
{enhancedError.link &&
1601-
(enhancedError.link.magic === "CONTACT_FORM" ? (
1602-
<Feedback
1603-
button={
1604-
<Button
1605-
variant="tertiary/medium"
1606-
LeadingIcon={EnvelopeIcon}
1607-
leadingIconClassName="text-blue-400"
1608-
fullWidth
1609-
textAlignLeft
1610-
>
1611-
{enhancedError.link.name}
1612-
</Button>
1613-
}
1614-
/>
1615-
) : (
1616-
<Callout variant="docs" to={enhancedError.link.href}>
1617-
{enhancedError.link.name}
1618-
</Callout>
1619-
))}
1620-
{enhancedError.stackTrace && (
1621-
<CodeBlock
1622-
showCopyButton={false}
1623-
showLineNumbers={false}
1624-
code={enhancedError.stackTrace}
1625-
maxLines={20}
1626-
/>
1627-
)}
1628-
</div>
1629-
);
1630-
}
1631-
}
1632-
}
16331559
function SpanEntity({ span }: { span: Span }) {
16341560
const isAdmin = useHasAdminAccess();
16351561

0 commit comments

Comments
 (0)