DEV Community

Timevolt
Timevolt

Posted on

Monitoring and Logging: The Force Awakens Your System's Health

The Quest Begins (The "Why")

I still remember the night our checkout service went dark. Users started tweeting about “cart errors”, the support queue exploded, and I was staring at a tail of console.log lines that told me nothing useful. No stack traces, no request IDs, just a flood of “Info: processing payment” messages that looked the same whether a transaction succeeded or crashed. It felt like trying to find a needle in a haystack while blindfolded — except the haystack was on fire and the needle was the reason our revenue was dripping away.

That moment was the dragon I needed to slay: silent failures. We had metrics, sure, but they were coarse‑grained CPU and memory graphs. When a specific endpoint started throwing 500s for a niche payment provider, the graphs didn’t twitch. Users felt the pain before we even knew there was a problem. I realized we needed a way to see what was happening inside the code, not just what the host reported.

The Revelation (The Insight)

The treasure I uncovered wasn’t a single tool; it was a mindset shift: observability. If you can’t ask a question about your system’s behavior and get an answer fast, you’re flying blind. The three pillars — logs, metrics, and traces — work together like a fellowship:

  • Logs give you the detailed story of what happened, when, and for whom.
  • Metrics give you the vital signs — rates, latencies, error percentages — that tell you if something is trending wrong.
  • Traces stitch those stories across service boundaries, showing the exact path a request took.

When you combine them, you can spot anomalies before they turn into user‑visible incidents. You get early warnings, you can correlate a spike in latency with a specific log pattern, and you can drill down to the exact line of code that misbehaved. It’s like finally discovering the One Ring in the depths of Mordor — once you have it, the whole landscape changes.

Wielding the Power (Code & Examples)

The Struggle: Bare‑bones Logging

Here’s what a typical Node.js route looked like before we got serious:

// before.js
const express = require('express');
const app = express();

app.post('/checkout', (req, res) => {
  console.log('Processing payment for order', req.body.orderId);
  // … lots of async work …
  const result = paymentProvider.charge(req.body.amount, req.body.token);
  if (result.error) {
    console.log('Payment failed'); // <-- useless! no context, no stack trace
    return res.status(500).send('Payment failed');
  }
  res.send({ success: true });
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

What’s wrong?

  • No request‑level identifier → you can’t tie logs from different services together.
  • console.log is synchronous and can block the event loop under load.
  • Errors are logged without a stack trace, making root‑cause guesswork a nightmare.

The Victory: Structured, Correlated Observability

After we adopted a proper logging library, added metrics, and instrumented tracing, the same endpoint became a beacon of insight:

// after.js
const express = require('express');
const { createLogger, format, transports } = require('winston');
const client = require('prom-client');
const { trace, context, propagation } = require('@opentelemetry/api');

// ---- Logger (Winston) ----
const logger = createLogger({
  level: 'info',
  format: format.combine(
    format.timestamp(),
    format.json() // easy to ingest by ELK, Loki, etc.
  ),
  transports: [new transports.Console()]
});

// ---- Metrics (Prometheus) ----
const httpRequestDuration = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'Duration of HTTP requests in seconds',
  labelNames: ['method', 'route', 'status_code'],
  buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5]
});
const httpRequestTotal = new client.Counter({
  name: 'http_requests_total',
  help: 'Total HTTP requests',
  labelNames: ['method', 'route', 'status_code']
});

// ---- OpenTelemetry Tracing ----
const tracer = trace.getTracer('checkout-service');

// Middleware to inject trace ID into logs and metrics
function observabilityMiddleware(req, res, next) {
  // Extract or create a trace context
  const parentCtx = context.active();
  const span = tracer.startSpan('HTTP request', undefined, parentCtx);
  span.setAttribute('http.method', req.method);
  span.setAttribute('http.route', req.path);

  // Generate a request ID for log correlation
  const requestId = req.headers['x-request-id'] ||
                    require('crypto').randomBytes(16).toString('hex');
  req.id = requestId;

  // Add request ID to Winston's default meta
  logger.add(new transports.Console({
    format: format.combine(
      format.timestamp(),
      format.errors({ stack: true }),
      format.splat(),
      format.json(),
      format((info) => {
        info.requestId = requestId;
        info.traceId = span.spanContext().traceId;
        return info;
      })()
    )
  }));

  // Start timer for duration metric
  const end = httpRequestDuration.startTimer({
    method: req.method,
    route: req.path
  });

  res.on('finish', () => {
    span.setAttribute('http.status_code', res.statusCode);
    span.end();

    httpRequestTotal.inc({
      method: req.method,
      route: req.path,
      status_code: res.statusCode
    });
    end(); // record duration
  });

  // Propagate trace context downstream
  propagation.inject(context.active(), req.headers);
  next();
}

// ---- Application ----
const app = express();
app.use(observabilityMiddleware);

app.post('/checkout', async (req, res) => {
  const span = tracer.startSpan('processPayment');
  try {
    logger.info('Starting payment processing', {
      orderId: req.body.orderId,
      amount: req.body.amount
    });

    const result = await paymentProvider.charge(
      req.body.amount,
      req.body.token
    );

    if (result.error) {
      logger.error('Payment failed', {
        orderId: req.body.orderId,
        error: result.error,
        stack: result.error.stack   // winston captures this automatically
      });
      return res.status(500).send('Payment failed');
    }

    logger.info('Payment succeeded', {
      orderId: req.body.orderId,
      transactionId: result.id
    });
    res.send({ success: true });
  } catch (err) {
    logger.error('Unexpected error during checkout', {
      orderId: req.body.orderId,
      error: err.message,
      stack: err.stack
    });
    res.status(500).send('Something went wrong');
  } finally {
    span.end();
  }
});

app.listen(3000, () => logger.info('Service listening on :3000'));
Enter fullscreen mode Exit fullscreen mode

What changed?

  1. Structured JSON logs – each line is a JSON object with timestamp, level, message, plus requestId and traceId. This lets our log aggregation system (e.g., Loki or Elasticsearch) filter by a single request and see the whole story.
  2. Correlation IDs – the x-request-id header (or a generated one) flows through every service, so a user’s journey appears as a single thread in our trace view.
  3. Metrics on every request – Prometheus histograms capture latency distributions; counters give us error rates per endpoint. Alerts fire when the 95th‑percentile latency spikes or when error rates exceed a threshold.
  4. Automatic stack traces – Winston’s format.errors({ stack: true }) ensures that whenever we log an error, we also capture the full stack, turning guesswork into precise line‑number debugging.
  5. Distributed tracing – OpenTelemetry creates spans for each async operation, visible in Jaeger or Tempo. If the payment provider’s API slows down, we see a wide span exactly where the latency lives, even if the error never bubbles up to a 500.

Traps to Avoid (The “What Not to Do”)

  • Over‑logging – dumping every variable at debug level in production fills storage and hurts performance. Keep logs at info for operational events and reserve debug for local dev.
  • Ignoring error context – logging err.message without a stack trace is like shouting “something broke!” and walking away. Always let your logger capture the stack (most libraries do this automatically if you pass the Error object).
  • Forgetting to propagate trace headers – if you drop the traceparent header when calling downstream services, your trace splits, and you lose the end‑to‑end view. Use the OpenTelemetry propagation utilities or a middleware that does it for you.

Why This New Power Matters

Since we put this observability stack in place, our incident response time dropped from hours to minutes. We now catch a misbehaving third‑party webhook before it throttles our payment flow, because the latency histogram creeps up and triggers a Slack alert. When a bug does slip through, we can pull up a trace, see the exact span where a null reference was thrown, and jump straight to the offending line in the codebase — no more “let’s add more logs and redeploy” cycles.

The confidence to ship faster is real. Knowing that any anomaly will shout at us before users feel it lets us experiment with feature flags, canary releases, and even chaos‑engineering games on Fridays. In short, we’ve turned our system from a black box into a transparent, reactive organism that tells us exactly how it’s feeling.

Your Turn: Start Your Own Quest

Pick a service you maintain — maybe an API endpoint, a background worker, or even a frontend SSR page.

  1. Add a request ID (or reuse an existing trace ID) and make sure it appears in every log line.
  2. Instrument a histogram for request latency and a counter for errors per route.
  3. Hook up a tracing library (OpenTelemetry, Jaeger client, AWS X‑Ray, etc.) and propagate the context across async calls and external HTTP requests.

When you’ve done that, try to generate a deliberate error (e.g., throw in a catch‑block) and watch how the log, metric, and trace line up to give you a full picture in seconds.

What’s the first metric you’ll add? Share your before/after snippets in the comments — let’s learn from each other’s quests! 🚀

Top comments (0)