Skip to content

Commit 984e756

Browse files
committed
Parse stack traces and add e2e test
1 parent 10518ec commit 984e756

File tree

19 files changed

+291
-161
lines changed

19 files changed

+291
-161
lines changed

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,5 @@ This project uses pnpm with workspace configuration. The required version is spe
159159
- Create a changeset using `pnpm changeset add`
160160
- All changed packages should be included in the changeset. Never include unchanged packages.
161161
- All changes should be marked as "patch". Never use "major" or "minor" modes.
162-
- Remember to always build any packages that get changed before running downstream tests like e2e tests in the workbench
162+
- Remember to always build any packages that get changed before running downstream tests like e2e tests in the workbench
163+
- Remember that changes made to one workbench should propogate to all other workbenches. The workflows should typically only be written once inside the example workbench and symlinked into all the other workbenches

packages/builders/src/base-builder.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -579,8 +579,8 @@ export const POST = workflowEntrypoint(workflowCode);`;
579579
loader: 'js',
580580
},
581581
outfile,
582-
// Source maps for the final workflow bundle wrapper (not critical since this code
583-
// doesn't run in the VM - the intermediate bundle at line 479 runs in the VM)
582+
// Source maps for the final workflow bundle wrapper (not impport since this code
583+
// doesn't run in the VM - only the intermediate bundle sourcemap is relevant)
584584
sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING,
585585
absWorkingDir: this.config.workingDir,
586586
bundle: true,

packages/core/e2e/e2e.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,4 +524,40 @@ describe('e2e', () => {
524524
expect(returnValue.retryableResult.duration).toBeGreaterThan(10_000);
525525
expect(returnValue.gotFatalError).toBe(true);
526526
});
527+
528+
test(
529+
'crossFileErrorWorkflow - stack traces work across imported modules',
530+
{ timeout: 60_000 },
531+
async () => {
532+
// This workflow intentionally throws an error from an imported helper module
533+
// to verify that stack traces correctly show cross-file call chains
534+
const run = await triggerWorkflow('crossFileErrorWorkflow', []);
535+
const returnValue = await getWorkflowReturnValue(run.runId);
536+
537+
// The workflow should fail with the error from the helper module
538+
expect(returnValue).toHaveProperty('error');
539+
expect(returnValue.error).toContain('Error from imported helper module');
540+
541+
// Verify the stack trace is present and shows correct file paths
542+
expect(returnValue).toHaveProperty('stack');
543+
expect(typeof returnValue.stack).toBe('string');
544+
545+
// Stack trace should include frames from the helper module (helpers.ts)
546+
expect(returnValue.stack).toContain('helpers.ts');
547+
expect(returnValue.stack).toContain('throwError');
548+
expect(returnValue.stack).toContain('callThrower');
549+
550+
// Stack trace should include frames from the workflow file (99_e2e.ts)
551+
expect(returnValue.stack).toContain('99_e2e.ts');
552+
expect(returnValue.stack).toContain('crossFileErrorWorkflow');
553+
554+
// Stack trace should NOT contain 'evalmachine' anywhere
555+
expect(returnValue.stack).not.toContain('evalmachine');
556+
557+
// Verify the run failed
558+
const { json: runData } = await cliInspectJson(`runs ${run.runId}`);
559+
expect(runData.status).toBe('failed');
560+
expect(runData.error).toContain('Error from imported helper module');
561+
}
562+
);
527563
});

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
},
4848
"dependencies": {
4949
"@aws-sdk/credential-provider-web-identity": "3.609.0",
50+
"@jridgewell/trace-mapping": "^0.3.31",
5051
"@standard-schema/spec": "^1.0.0",
5152
"@types/ms": "^2.1.0",
5253
"@vercel/functions": "catalog:",

packages/core/src/runtime.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ import {
4242
buildWorkflowSuspensionMessage,
4343
getWorkflowRunStreamId,
4444
} from './util.js';
45-
import { runWorkflow } from './workflow.js';
45+
import { remapErrorStack, runWorkflow } from './workflow.js';
46+
import { parseWorkflowName } from './parse-name.js';
4647

4748
export type { Event, WorkflowRun };
4849
export { WorkflowSuspension } from './global.js';
@@ -518,15 +519,30 @@ export function workflowEntrypoint(workflowCode: string) {
518519
}
519520
} else {
520521
const errorName = getErrorName(err);
521-
const errorStack = getErrorStack(err);
522+
let errorStack = getErrorStack(err);
523+
524+
// Remap error stack using source maps to show original source locations
525+
if (errorStack) {
526+
const parsedName = parseWorkflowName(workflowName);
527+
const filename = parsedName?.path || workflowName;
528+
errorStack = remapErrorStack(
529+
errorStack,
530+
filename,
531+
workflowCode
532+
);
533+
}
534+
522535
console.error(
523536
`${errorName} while running "${runId}" workflow:\n\n${errorStack}`
524537
);
538+
539+
// Store both the error message and remapped stack trace
540+
const errorString = errorStack || String(err);
541+
525542
await world.runs.update(runId, {
526543
status: 'failed',
527-
error: String(err),
544+
error: errorString,
528545
// TODO: include error codes when we define them
529-
// TODO: serialize/include the error name and stack?
530546
});
531547
span?.setAttributes({
532548
...Attribute.WorkflowRunStatus('failed'),

packages/core/src/source-map.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping';
2+
3+
/**
4+
* Remaps an error stack trace using inline source maps to show original source locations.
5+
*
6+
* @param stack - The error stack trace to remap
7+
* @param filename - The workflow filename to match in stack frames
8+
* @param workflowCode - The workflow bundle code containing inline source maps
9+
* @returns The remapped stack trace with original source locations
10+
*/
11+
export function remapErrorStack(
12+
stack: string,
13+
filename: string,
14+
workflowCode: string
15+
): string {
16+
// Extract inline source map from workflow code
17+
const sourceMapMatch = workflowCode.match(
18+
/\/\/# sourceMappingURL=data:application\/json;base64,(.+)/
19+
);
20+
if (!sourceMapMatch) {
21+
return stack; // No source map found
22+
}
23+
24+
try {
25+
const base64 = sourceMapMatch[1];
26+
const sourceMapJson = Buffer.from(base64, 'base64').toString('utf-8');
27+
const sourceMapData = JSON.parse(sourceMapJson);
28+
29+
// Use TraceMap (pure JS, no WASM required)
30+
const tracer = new TraceMap(sourceMapData);
31+
32+
// Parse and remap each line in the stack trace
33+
const lines = stack.split('\n');
34+
const remappedLines = lines.map((line) => {
35+
// Match stack frames: "at functionName (filename:line:column)" or "at filename:line:column"
36+
const frameMatch = line.match(
37+
/^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/
38+
);
39+
if (!frameMatch) {
40+
return line; // Not a stack frame, return as-is
41+
}
42+
43+
const [, functionName, file, lineStr, colStr] = frameMatch;
44+
45+
// Only remap frames from our workflow file
46+
if (!file.includes(filename)) {
47+
return line;
48+
}
49+
50+
const lineNumber = parseInt(lineStr, 10);
51+
const columnNumber = parseInt(colStr, 10);
52+
53+
// Map to original source position
54+
const original = originalPositionFor(tracer, {
55+
line: lineNumber,
56+
column: columnNumber,
57+
});
58+
59+
if (original.source && original.line !== null) {
60+
const func = functionName || original.name || 'anonymous';
61+
const col = original.column !== null ? original.column : columnNumber;
62+
return ` at ${func} (${original.source}:${original.line}:${col})`;
63+
}
64+
65+
return line; // Couldn't map, return original
66+
});
67+
68+
return remappedLines.join('\n');
69+
} catch (e) {
70+
// If source map processing fails, return original stack
71+
return stack;
72+
}
73+
}

packages/core/src/workflow.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import { createCreateHook } from './workflow/hook.js';
3030
import { createSleep } from './workflow/sleep.js';
3131
import { parseWorkflowName } from './parse-name.js';
3232

33+
export { remapErrorStack } from './source-map.js';
34+
3335
export async function runWorkflow(
3436
workflowCode: string,
3537
workflowRun: WorkflowRun,

0 commit comments

Comments
 (0)