While working on a logging project, I encountered a situation that I had not really dealt with before. The project itself, ‘Logxar’, needs to be able to log messages at different levels, which are defined as follows:
class LogLevel {
static #Debug = 0
static #Info = 1
static #Warn = 2
static #Error = 3
static #Critical = 4
}
This setup allows me to decide precisely how much logging information appears in my terminal.
However, simply dumping plain text into standard output (or into a file, in my case) isn’t enough. I need more context to make debugging easier.
The info I need is:
- Time the log was executed
- Log level
- File where it happened
- Line where it happened
- Log message
So far, so good. But how do I get that info with plain native JS? That’s where the stack trace comes in.
Short reminder: Call Stack
A stack is a very common data structure in programming. Designed to store a collection of items, it follows the Last In, First Out (LIFO) principle. In other words, whatever you put in last comes out first.
You see stacks in everyday life too:
- A stack of books
- A stack of papers
- A stack of pizzas
In all of those cases, the last item placed on top is the first one retrieved.
Now for the interesting part: a call stack is a special type of stack that keeps track of function calls. Each time a function is invoked, information about it is pushed onto the stack. Here’s a quick example:
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
function calculate() {
const x = 10;
const y = 5;
const result1 = add(x, y);
const result2 = multiply(add(1, 2), result1);
return result2;
}
calculate();
When calculate is called, its information goes on top of the stack. Then it calls add, which also pushes its information to the stack. Once add has finished, multiply is added to the call stack, and so on.
The Solution
The goal here is to get the stack trace, which is essentially a snapshot of the call stack at a specific point in time and provides an overview of its current state.
My first thought was to use console.trace():
function foo() {
console.trace("Calling foo");
}
foo();
Output:
Trace: Calling foo
at foo (C:\Users\lazar\dev\test\index.js:2:11)
at bar (C:\Users\lazar\dev\test\index.js:10:3)
at main (C:\Users\lazar\dev\test\index.js:6:3)
...
It looks perfect, doesn’t it? It prints the call stack at that point in execution process. You can clearly see main calls bar, which then calls foo, where the log was triggered.
The catch? You can’t interact with this programmatically, it just spits everything out to stderr.
The real solution I found (without using external libraries) is to leverage the Error class:
try {
throw new Error("Test error");
} catch (error) {
console.log(error.stack);
}
Output:
Error: Test error
at foo (C:\Users\lazar\dev\test\index.js:3:11)
at bar (C:\Users\lazar\dev\test\index.js:14:3)
at main (C:\Users\lazar\dev\test\index.js:10:3)
...
Perfect! Now we can access the stack property of the Error object, store it as a string, and parse/manipulate it however we like.
You can even make this nicer with Error.captureStackTrace:
const debugInfo = {}
Error.captureStackTrace(debugInfo)
console.log(debugInfo.stack)
Output:
Error
at foo (C:\Users\lazar\dev\test\index.js:3:9)
at bar (C:\Users\lazar\dev\test\index.js:12:3)
at main (C:\Users\lazar\dev\test\index.js:8:3)
Much better! This is a clean, programmatic way to grab the stack trace at any point during execution. But there’s one more problem…
How do I know which exact line I need exactly?
As I explained before, the call stack is LIFO; the top line is always the most recent function call (where the stack was captured).
So, if you know your code’s execution flow (I’m sure you do), you can technically deduce which line you actually want. For example:
function level1() {
level2();
}
function level2() {
level3();
}
function level3() {
const err = new Error("something happened");
Error.captureStackTrace(err);
console.log(err.stack);
}
level1();
Output:
Error: something happened
at level3 (<your file>:9:9)
at level2 (<your file>:5:3)
at level1 (<your file>:2:3)
at <anonymous> (<your file>:13:1)
If you already know the execution flow (which always goes level1 → level2 → level3), you can guess that the line you really want for logging is the second one (at level2).
So you could do something like this:
function level3() {
const err = new Error();
Error.captureStackTrace(err);
const lines = err.stack.split("\n");
const relevant = lines[2].trim();
console.log(relevant);
}
In my opinion, it’s elegant because you capture the full stack trace, but also generate detailed, focused logs that improve the developer experience.