DEV Community

Cover image for I added userId and transactionId to every console.log without refactoring
Alex Radulovic
Alex Radulovic

Posted on • Originally published at purpleowl.io

I added userId and transactionId to every console.log without refactoring

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);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Then, after your auth middleware and before your routes:

app.use(loggerMiddleware());
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

After:

{
  "ts": "2025-01-15T10:23:01.000Z",
  "level": "info",
  "userId": "alex_123",
  "txId": "abc-789",
  "msg": "contact created"
}
Enter fullscreen mode Exit fullscreen mode

Same code. Different outcome.

Now when you have this deeper in your codebase:

function updateContact(data) {
  console.log('updating contact', data.id);
}
Enter fullscreen mode Exit fullscreen mode

The output automatically becomes:

{
  "level": "info",
  "userId": "alex_123",
  "txId": "abc-789",
  "msg": "updating contact",
  "args": [12345]
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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');
  }
);
Enter fullscreen mode Exit fullscreen mode

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")'
Enter fullscreen mode Exit fullscreen mode

Get It

npm i @purpleowl-io/tracepack
Enter fullscreen mode Exit fullscreen mode

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)