Handling Anonymous and Eval Frames in Stack Traces
Error trackers that group by function name collapse silently when the top frames contain <anonymous>, eval at <anonymous>, or [native code] — turning hundreds of distinct failure points into a single unactionable bucket. This page shows you how to classify each frame by type and derive a stable positional signature so your grouping survives the anonymous-function patterns endemic to modern JavaScript, and fits into the broader cross-browser stack trace normalization work described under Source Map Generation & Stack Trace Debugging.
Symptom / Trigger
Your error tracker shows one enormous group labelled <anonymous> or Object.<anonymous> absorbing errors from dozens of unrelated code paths. Inspecting the raw stack strings reveals why — every engine produces a different flavour of the same unparseable frame:
# V8 (Chrome / Node.js) — anonymous callback
Error: network timeout
at <anonymous>:1:23
at Array.forEach (<anonymous>)
at Object.<anonymous> (bundle.min.js:1:9823)
at Module.<anonymous> (bundle.min.js:1:14502)
# V8 — eval frame (nested location)
TypeError: Cannot read properties of undefined
at eval (eval at <anonymous> (app.js:12:5), <anonymous>:3:9)
at callWith (app.js:12:5)
# V8 — native built-in
at Array.prototype.forEach (native)
at processItems (bundle.min.js:1:44100)
# SpiderMonkey (Firefox) — anonymous arrow function
[email protected]:1:8001
@bundle.min.js:1:8210
Array.prototype.forEach@[native code]
<anonymous>@debugger eval code:1:1
All four patterns defeat naïve functionName-based grouping.
Root Cause Explanation
Anonymous functions carry an empty or synthetic functionName property. Arrow functions in IIFE patterns inherit no lexical name. eval() embeds a nested location string that looks like a source reference but is not a real file path. Native built-ins report no fileName or line number at all.
A grouping key derived solely from functionName will map every one of these distinct failures to the same bucket:
// BROKEN: groups by functionName alone — collapses all anonymous frames
function groupingKey(frame) {
// Returns "" or "<anonymous>" for most frames in a minified bundle.
// Every distinct anonymous callback maps to the same key — useless.
return frame.functionName || "<anonymous>";
}
When functionName is empty, "<anonymous>", "Object.<anonymous>", or "Array.prototype.forEach", this key is shared across completely unrelated code paths.
Step-by-Step Fix
Step 1 — Classify each frame by type
Inspect both functionName and fileName to assign one of four mutually exclusive types. Perform the checks in the order shown — eval frames always win over anonymous because they have a recognisable fileName pattern.
/**
* @typedef {'named'|'anonymous'|'eval'|'native'} FrameType
*
* @param {{ functionName?: string, fileName?: string,
* lineNumber?: number, columnNumber?: number }} frame
* @returns {FrameType}
*/
function classifyFrame(frame) {
const fn = frame.functionName ?? "";
const file = frame.fileName ?? "";
// eval frames: V8 sets fileName to a string that starts with "eval at"
// or wraps it in parentheses; SpiderMonkey uses "debugger eval code"
if (file.startsWith("eval at") || file.includes("eval code")) {
return "eval";
}
// native frames: no fileName at all, or the literal string "[native code]"
if (!file || file === "[native code]" || file.startsWith("native")) {
return "native";
}
// named frames: functionName is present and not a generic placeholder
const genericNames = new Set(["<anonymous>", "Object.<anonymous>",
"Module.<anonymous>", ""]);
if (fn && !genericNames.has(fn)) {
return "named";
}
// everything else is an anonymous frame with a real file position
return "anonymous";
}
Step 2 — Stable positional signature for anonymous frames
When the function name is absent, the file + line + column triplet is the only stable identifier across two separate Error instances thrown at the same code point. Derive the signature from that:
/**
* Produces a stable string for an anonymous frame that has a known file
* position. Works even after minification because line:col is stable
* within a given build artifact.
*
* @param {{ fileName: string, lineNumber: number, columnNumber: number }} frame
* @returns {string}
*/
function anonymousSignature(frame) {
const file = frame.fileName ?? "unknown";
const line = frame.lineNumber ?? 0;
const col = frame.columnNumber ?? 0;
// Normalise the path to just the filename to avoid absolute-path drift
// between environments. Use the full path if you control deployments.
const basename = file.split("/").pop() ?? file;
return `anon@${basename}:${line}:${col}`;
}
Step 3 — Extract the outer call site from eval frames
V8 encodes eval origins as a nested location string. The outer call site (the real source file that called eval()) is more stable and more useful for grouping than the synthetic inner position:
// V8 eval frame fileName examples:
// "eval at callWith (app.js:12:5), <anonymous>:3:9"
// "eval at <anonymous> (bundle.min.js:1:9823), <anonymous>:2:1"
//
// SpiderMonkey fileName: "debugger eval code"
// SpiderMonkey functionName: "<anonymous>" or empty
/**
* Extracts outer (call-site) and inner (eval body) positions from a V8
* eval frame fileName string.
*
* @param {string} fileName — raw fileName from the parsed stack frame
* @returns {{ outer: string|null, inner: string|null }}
*/
function parseEvalFrame(fileName) {
// Matches: "eval at CALLEE (FILE:LINE:COL), <anonymous>:ILINE:ICOL"
const V8_EVAL_RE =
/^eval at (?:[^(]+) \(([^)]+):(\d+):(\d+)\),\s*<anonymous>:(\d+):(\d+)$/;
const m = V8_EVAL_RE.exec(fileName);
if (m) {
return {
outer: `${m[1]}:${m[2]}:${m[3]}`, // outer call site — use for grouping
inner: `<eval>:${m[4]}:${m[5]}`, // inner eval body position
};
}
// SpiderMonkey / unrecognised format — fall back to whole string
return { outer: fileName, inner: null };
}
function evalSignature(frame) {
const { outer } = parseEvalFrame(frame.fileName ?? "");
// Prefer outer; if unavailable, use the functionName call site
return `eval@${outer ?? frame.functionName ?? "unknown"}`;
}
Step 4 — Deterministic signature for native frames
Native built-in frames carry the method name in functionName but no file or line number. Mark them explicitly so downstream symbolication pipelines skip them:
/**
* @param {{ functionName?: string }} frame
* @returns {{ signature: string, isNative: true, symbolizable: false }}
*/
function nativeSignature(frame) {
// Examples: "Array.prototype.forEach", "Promise.resolve", "[native code]"
// WebAssembly frames surface as "wasm-function[42]" with no file — same path.
const name = frame.functionName || "[native]";
return {
signature: `native@${name}`,
isNative: true,
symbolizable: false,
};
}
Step 5 — Assemble the frameSignature() dispatcher
Wire the four classification outcomes into a single function that error-grouping code can call on every parsed frame:
/**
* Returns a stable, human-readable signature string for a parsed stack
* frame that can be used as a grouping key in an error tracker.
*
* @param {{ functionName?: string, fileName?: string,
* lineNumber?: number, columnNumber?: number }} frame
* @returns {string}
*/
function frameSignature(frame) {
const type = classifyFrame(frame);
switch (type) {
case "named":
// Use functionName directly — it is stable within a build artifact.
// Optionally strip webpack module wrappers like "Module.<anonymous>."
return `named@${frame.functionName}`;
case "anonymous":
return anonymousSignature(frame);
case "eval":
return evalSignature(frame);
case "native": {
// Destructure to get just the signature string for grouping;
// attach the full object to the frame for symbolication pipelines.
const info = nativeSignature(frame);
Object.assign(frame, { isNative: info.isNative,
symbolizable: info.symbolizable });
return info.signature;
}
}
}
// Example grouping — produce one bucket key from the top N frames:
function errorGroupKey(parsedFrames, depth = 3) {
return parsedFrames
.slice(0, depth)
.map(frameSignature)
.join("|");
}
Verification
Run this unit test to confirm that two separate Error instances thrown inside the same anonymous callback produce an identical frameSignature() even though their messages differ:
// Node.js — run with: node --experimental-vm-modules test-frame-sig.mjs
// Or paste into a browser console.
import assert from "node:assert/strict";
// Simulate two parsed frames from the same anonymous callback at the same
// file position (as a real stack parser would produce):
const frameA = { functionName: "", fileName: "bundle.min.js",
lineNumber: 1, columnNumber: 8823 };
const frameB = { functionName: "<anonymous>", fileName: "bundle.min.js",
lineNumber: 1, columnNumber: 8823 };
const sigA = frameSignature(frameA);
const sigB = frameSignature(frameB);
assert.equal(sigA, sigB,
"Two anonymous frames at the same position must produce identical signatures");
assert.equal(sigA, "[email protected]:1:8823",
"Signature format must be anon@file:line:col");
// Confirm eval frames use the outer call site, not the inner eval body:
const evalFrame = {
functionName: "eval",
fileName: "eval at callWith (app.js:12:5), <anonymous>:3:9",
lineNumber: 3,
columnNumber: 9,
};
assert.equal(frameSignature(evalFrame), "[email protected]:12:5",
"Eval signature must prefer the outer call site");
console.log("All assertions passed.");
Expected output:
All assertions passed.
Edge Cases & Gotchas
-
Indirect eval produces a different format. When
eval()is called inside a named function rather than at the top level, V8 may emit"eval at outerFn (file:line:col)"without the trailing<anonymous>:inner:innersegment. The regex in Step 3 handles this gracefully by falling back to the rawfileNamestring, but you should log unmatched patterns so you can extend the regex as new engine versions ship. -
Inferred-name recovery via
Function.prototype.toString()breaks under minification. Some libraries attempt to recover an inferred function name by stringifying the function and scanning for afunction <name>token. This returns"function e("or similar after minification — a mangled name that changes between builds. Do not use this technique for grouping keys. -
WebAssembly frames look like native frames. Wasm functions appear as
"wasm-function[12]"infunctionNamewith nofileName. TheclassifyFramelogic above routes them through thenativebranch, which is correct — Wasm frames are not symbolizable via JavaScript source maps. Track them separately if your error tracker supports a wasm-specific symbolication pipeline. -
Bundler-injected shims pollute the top frames. Webpack’s runtime bootstrap (
webpack_require,__webpack_modules__) and polyfill shims (core-js, regenerator-runtime) appear in every stack trace and carry no meaningful semantic information. Strip them before computingerrorGroupKey()by maintaining a frame-filter allowlist based on URL patterns — for example, drop any frame whosefileNamematches/webpack[_-](require|runtime)/or/\/node_modules\/core-js\//.
FAQ
Why not just use Error.stack as the entire grouping key?
The full Error.stack string includes the error message, which varies between instances (it may contain user IDs, request URLs, or timestamps). Even stripping the first line leaves you with a string that differs between browsers, engine versions, and build artifacts. Parsing the stack into frames and signing each frame independently lets you normalise across V8 and SpiderMonkey output formats — see Normalizing V8 and SpiderMonkey Stack Frames for the parsing step that feeds into this pipeline.
Does source-map symbolication help before I apply this classification?
Apply source-map lookup first if you have maps available — it will resolve anonymous positional frames back to named original functions and turn [email protected]:1:8823 into something like named@processPayment. Classification and positional signatures are the fallback for frames that cannot be symbolicated, or for teams that have not yet deployed source maps. See Local Symbolication with the Mozilla Source Map Library for the symbolication layer.
My error tracker already deduplicates by stack hash — do I still need this?
Stack-hash deduplication typically hashes the raw Error.stack string or a normalised form of it. Without frame classification, two identical errors in different environments (Chrome vs Firefox) produce different hashes and land in different groups. Replacing the raw function names with typed signatures — anon@, eval@, native@ — before hashing makes the hash engine-agnostic and build-agnostic, so production and staging errors merge into the same group.
Related
- Normalizing V8 and SpiderMonkey Stack Frames — parsing the raw
Error.stackstring into structured frame objects that feed the classification pipeline above - Cross-Browser Source Map and Stack Compatibility — reconciling source map format differences that affect symbolication of anonymous and eval frames
- Local Symbolication with the Mozilla Source Map Library — resolving positional signatures back to original source names before falling back to anonymous grouping
- Debugging Minified Code Without Source Maps — manual techniques when neither source maps nor original names are available