Almost every JavaScript server starts the same way.
You add a few console.log() calls so you can see what's happening. You deploy. Everything works.
For a while, this is completely fine.
The problem is that this version of "fine" only exists while your system is still small.
As soon as you have multiple users, concurrent requests, and background jobs, logging quietly stops being helpful. Nothing breaks—but nothing is understandable anymore.
What Logging Looks Like Right Before It Fails You
A single user action might:
- create or update several records
- trigger validations
- enqueue background work
- call external APIs
- finish seconds or minutes later
All of that generates logs. Now multiply by hundreds of users doing the same thing at the same time.
A log like this:
console.log('updating contact', contactId);
Is fine—until you see it 10,000 times a day and have no idea which one matters.
The Conversation Every Team Has (and Then Avoids)
Eventually someone says: "We should really fix logging."
What that usually means is:
- passing user IDs everywhere
- threading request IDs through layers
- replacing
console.log()with a custom logger - touching hundreds of files
For small teams, that's not a small task. It's a refactor with no visible feature payoff, so it gets kicked down the road.
We've done that ourselves.
So instead of designing a "proper" logging framework, we imposed a hard constraint:
If adopting this requires rewriting the app, we won't use it.
The Entire Setup (Yes, Really)
At your application entry point:
import { replaceConsole, loggerMiddleware } from '@purpleowl-io/tracepack';
replaceConsole();
Then, after your auth middleware and before your routes:
app.use(loggerMiddleware());
That's it. Two lines.
You don't change your existing logs. You don't update call sites. You don't teach the team a new API.
What Your Logs Look Like After
Before:
contact created
After:
{
"ts": "2025-01-15T10:23:01.000Z",
"level": "info",
"userId": "alex_123",
"txId": "abc-789",
"msg": "contact created"
}
Same code. Different outcome.
Now when you have this deeper in your codebase:
function updateContact(data) {
console.log('updating contact', data.id);
}
The output automatically becomes:
{
"level": "info",
"userId": "alex_123",
"txId": "abc-789",
"msg": "updating contact",
"args": [12345]
}
You didn't pass a user ID. You didn't pass a transaction ID. The context followed the execution for you.
Why This Works Without Being Fragile
Under the hood, this uses Node's AsyncLocalStorage to attach context to the execution path, not to individual function calls.
That context survives:
await- promises
- database drivers
- network calls
- timers
- background work
Once a request starts, everything it touches can log with the same identity—even if the work fans out across multiple async layers.
Adding Business Context
Sometimes user + transaction isn't enough.
import { log } from '@purpleowl-io/tracepack';
log.addContext({ orderId: req.body.id });
From that point forward, every log in that async chain includes orderId.
Background Jobs and Scripts
For non-HTTP code, you can explicitly establish context:
import { withContext } from '@purpleowl-io/tracepack';
await withContext(
{ userId: 'system', txId: 'nightly-job-001' },
async () => {
console.log('starting batch');
await processBatch();
console.log('batch complete');
}
);
Those logs still look exactly like request logs—structured, searchable, and correlated.
Filtering With jq
All output is clean JSON, one log entry per line:
# Filter by transaction
node app.js | jq 'select(.txId == "abc-789")'
# Filter by user
node app.js | jq 'select(.userId == "alex_123")'
# Errors only
node app.js | jq 'select(.level == "error")'
Get It
npm i @purpleowl-io/tracepack
- npm: https://www.npmjs.com/package/@purpleowl-io/tracepack
- GitHub: https://github.com/purpleowl-io/tracepack
We built this because we were tired of reading logs that couldn't answer the questions we actually had.
If your system is still small enough that logs are readable by default, great. Enjoy it while it lasts.
If you've crossed the line where concurrency has turned debugging into guesswork, this is a small change that pays off every single time something breaks.
Happy to answer questions about the implementation or tradeoffs.
Top comments (0)