Phase 8 - Observability | Post 12 | Logging, Metrics, and Tracing — Understanding What Is Happening Inside Your App

Post 12 of 15 | Phase 8: Observability


Logging, Metrics, and Tracing — Understanding What Is Happening Inside Your App

You have built a working microservices backend. Services are communicating, data is persisting to MongoDB, authentication is working. Now comes the question that every developer faces in production: when something goes wrong, how do you find out what happened and where?

In a monolith you add a console.log and check one log file. In microservices you have ten services running, each producing its own logs, handling hundreds of requests per second. You need proper tools to make sense of it all.

Observability has three pillars:

  • Logging — what happened and when
  • Metrics — how is the system performing right now
  • Tracing — how did a specific request travel through the system

Moleculer has built-in support for all three.


Pillar 1: Logging

You have been using this.logger throughout the course. Let us now understand logging properly and set it up for real use.

Log Levels

Moleculer has six log levels in order from most to least verbose:

trace   — extremely detailed, every internal step
debug   — debugging information, variable values
info    — normal operational messages, service started, action called
warn    — something unexpected happened but the app is still running
error   — something failed, needs attention
fatal   — critical failure, app may not recover

In development use info or debug. In production use warn or error to reduce noise and storage cost.

Configuring the Logger in moleculer.config.js

module.exports = {
    logger: {
        type: "Console",
        options: {
            // Show colors in terminal output
            colors: true,

            // Different colors for different services
            moduleColors: true,

            // full shows timestamp, level, nodeID, service name, message
            // short shows less information
            // simple shows just level and message
            // json outputs machine-readable JSON — use in production
            formatter: "full",

            // Automatically pad service names so logs align in columns
            autoPadding: true
        }
    },

    logLevel: "info"
};

Using the Logger in Services

module.exports = {
    name: "order",

    actions: {
        async create(ctx) {
            // Different log levels for different situations
            this.logger.debug("create action called", ctx.params);
            this.logger.info(`Creating order for user ${ctx.meta.user.id}`);

            try {
                const order = await this.adapter.insert(ctx.params);
                this.logger.info(`Order created successfully: ${order._id}`);
                return order;
            } catch (err) {
                this.logger.error("Failed to create order", err);
                throw err;
            }
        }
    }
};

Multiple Loggers at Once

In production you often want logs to go to both the console and a file or external service. Moleculer supports multiple loggers simultaneously:

module.exports = {
    logger: [
        // Console logger for development visibility
        {
            type: "Console",
            options: {
                formatter: "short",
                colors: true
            }
        },

        // File logger — writes to a file
        {
            type: "File",
            options: {
                // Log file location
                filename: "logs/moleculer-{date}.log",

                // New file every day
                eol: "\n",

                // Format as JSON for log parsing tools
                formatter: "json"
            }
        }
    ],

    logLevel: {
        // Different levels for different services
        // "*" is the default for all services
        "*": "warn",

        // Show more detail for the order service
        "ORDER": "debug",

        // Show all logs from the broker itself
        "BROKER": "info"
    }
};

Structured Logging — Logging Objects Not Strings

In production, logs are parsed by tools like Datadog, Elasticsearch, or CloudWatch. These tools work best with structured JSON logs. Always log objects alongside messages instead of concatenating strings:

// Bad — hard to parse programmatically
this.logger.info("Order " + orderId + " created for user " + userId + " with total " + total);

// Good — structured, easy to parse and filter
this.logger.info("Order created", {
    orderId,
    userId,
    total,
    productId,
    timestamp: new Date().toISOString()
});

Pillar 2: Metrics

Metrics tell you how your system is performing right now. Things like:

  • How many requests per second is each service handling
  • What is the average response time of each action
  • How many errors are occurring
  • How much memory is being used
  • How many active connections exist

Moleculer has a built-in metrics system that can expose these numbers to external monitoring tools like Prometheus and Grafana.

Enabling Metrics in moleculer.config.js

module.exports = {
    metrics: {
        enabled: true,

        // Where to send metrics
        reporter: [
            // Console reporter — prints metrics to terminal periodically
            {
                type: "Console",
                options: {
                    // Print metrics every 10 seconds
                    interval: 10
                }
            },

            // Prometheus reporter — exposes metrics at an HTTP endpoint
            // Grafana reads from Prometheus to create dashboards
            {
                type: "Prometheus",
                options: {
                    // Metrics available at http://localhost:3030/metrics
                    port: 3030,
                    path: "/metrics",

                    // Default labels added to every metric
                    defaultLabels: (registry) => ({
                        namespace: registry.broker.namespace,
                        nodeID: registry.broker.nodeID
                    })
                }
            }
        ]
    }
};

Install the Prometheus reporter:

npm install @moleculer/lab

Actually for Prometheus you need:

npm install moleculer-prometheus

When Prometheus metrics are enabled, visit http://localhost:3030/metrics and you will see output like this:

# HELP moleculer_request_total Total count of requests
# TYPE moleculer_request_total counter
moleculer_request_total{action="user.login",nodeID="node-1"} 42

# HELP moleculer_request_time_milliseconds Request time in milliseconds
# TYPE moleculer_request_time_milliseconds histogram
moleculer_request_time_milliseconds_bucket{action="user.login",le="10"} 38
moleculer_request_time_milliseconds_bucket{action="user.login",le="100"} 42

# HELP moleculer_request_error_total Total count of request errors
# TYPE moleculer_request_error_total counter
moleculer_request_error_total{action="user.login",nodeID="node-1"} 2

Prometheus scrapes this endpoint periodically and stores the data. Grafana connects to Prometheus and displays it as beautiful dashboards.

Built-in Metrics Moleculer Tracks Automatically

When metrics are enabled, Moleculer automatically tracks these without any code from you:

moleculer.request.total           — total requests per action
moleculer.request.active          — currently processing requests
moleculer.request.error.total     — total errors per action
moleculer.request.time            — response time histogram per action
moleculer.event.total             — total events emitted
moleculer.transporter.sent.total  — messages sent via transporter
moleculer.process.memory.heap     — Node.js heap memory usage
moleculer.process.cpu             — CPU usage percentage
moleculer.os.memory.free          — free system memory

All of this is tracked automatically. Zero code needed.

Custom Metrics

You can also track your own business metrics:

module.exports = {
    name: "order",

    async started() {
        // Register a custom counter metric
        this.broker.metrics.register({
            type: "counter",
            name: "order.created.total",
            labelNames: ["status"],
            description: "Total orders created"
        });

        // Register a custom gauge metric
        this.broker.metrics.register({
            type: "gauge",
            name: "order.revenue.total",
            description: "Total revenue from orders"
        });
    },

    actions: {
        create: {
            async handler(ctx) {
                const order = await this.adapter.insert(ctx.params);

                // Increment your custom counter
                this.broker.metrics.increment("order.created.total", {
                    status: "confirmed"
                });

                // Update your custom gauge
                this.broker.metrics.increment(
                    "order.revenue.total",
                    null,
                    order.totalPrice
                );

                return order;
            }
        }
    }
};

Now your Prometheus dashboard shows business metrics alongside system metrics. You can see total orders created and total revenue in real time.


Pillar 3: Tracing

This is the most powerful observability tool for microservices. Tracing shows you the complete journey of a single request as it travels through multiple services.

Without tracing, when a request is slow or fails, you know something went wrong but not where. With tracing you can see:

  • request started at API Gateway at 10:00:00.000
  • API Gateway called order.create at 10:00:00.005 — took 250ms
  • order.create called user.get at 10:00:00.010 — took 50ms
  • order.create called product.get at 10:00:00.060 — took 30ms
  • order.create called product.reduceStock at 10:00:00.090 — took 80ms
  • order.create completed at 10:00:00.255
  • API Gateway responded at 10:00:00.260

You can see exactly where time was spent. If product.get took 5 seconds suddenly, you know immediately where to look.

Enabling Tracing in moleculer.config.js

module.exports = {
    tracing: {
        enabled: true,

        exporter: [
            // Console exporter — prints trace spans to terminal
            // Good for development
            {
                type: "Console",
                options: {
                    // Only log spans that take more than 5ms
                    // Reduces noise from fast operations
                    logger: null,
                    colors: true,
                    width: 100,
                    gaugeWidth: 40
                }
            },

            // Jaeger exporter — sends traces to Jaeger UI
            // Best tool for visualizing traces in development
            {
                type: "Jaeger",
                options: {
                    // Jaeger collector endpoint
                    endpoint: null,
                    host: "localhost",
                    port: 6832,

                    // Send all spans (1.0 = 100%, 0.1 = 10%)
                    sampler: {
                        type: "Const",
                        options: { decision: 1 }
                    }
                }
            }
        ]
    }
};

Setting Up Jaeger Locally

Jaeger is the most popular distributed tracing tool. Run it with Docker (we cover Docker fully in Post 14, but use this command now if you have Docker):

docker run -d --name jaeger \
  -p 6831:6831/udp \
  -p 16686:16686 \
  jaegertracing/all-in-one:latest

Then open http://localhost:16686 in your browser. This is the Jaeger UI where you can visualize traces.

If you do not have Docker yet, use the Console exporter for now and come back to Jaeger in Post 14.

Console Tracing Output

With the Console exporter enabled, when you make a request you will see output like this in your terminal:

=== REQUEST TRACE ===
api.rest                          ████████████████████████ 255ms
  order.create                      ██████████████████ 245ms
    user.get                          ████ 50ms
    product.get                       ███ 30ms
    product.reduceStock               ██████ 80ms

This visual representation shows you the full call chain and how long each step took.

Adding Custom Trace Spans

When you have a complex operation inside a single action, you can break it into named spans to see exactly where time is spent:

module.exports = {
    name: "order",

    actions: {
        create: {
            async handler(ctx) {
                // Start a custom span for the validation step
                const validationSpan = this.broker.tracer.startSpan(
                    "Validate order data",
                    { parentSpan: ctx.span }
                );

                // Do validation work
                await this.validateOrderData(ctx.params);

                // End the validation span
                validationSpan.finish();

                // Start a span for the database insert
                const dbSpan = this.broker.tracer.startSpan(
                    "Save order to database",
                    { parentSpan: ctx.span }
                );

                const order = await this.adapter.insert(ctx.params);

                dbSpan.finish();

                return order;
            }
        }
    }
};

Now in Jaeger you will see the order.create span broken into sub-spans showing exactly how long validation took versus database insertion.

Adding Tags to Spans

Tags let you attach metadata to a span so you can filter and search traces:

actions: {
    create: {
        async handler(ctx) {
            // Add tags to the current span
            ctx.span.setTag("userId", ctx.meta.user.id);
            ctx.span.setTag("productId", ctx.params.productId);
            ctx.span.setTag("quantity", ctx.params.quantity);

            const order = await this.adapter.insert(ctx.params);

            ctx.span.setTag("orderId", order._id.toString());

            return order;
        }
    }
}

In Jaeger you can search for traces by tag. Find all orders for a specific user, or all orders for a specific product, by searching the tag values.


Complete Observability Configuration

Here is a complete moleculer.config.js with all three pillars enabled for production:

"use strict";

require("dotenv").config();

module.exports = {
    namespace: "ecommerce",
    nodeID: null,

    // Logging
    logger: [
        {
            type: "Console",
            options: {
                formatter: "full",
                colors: true,
                moduleColors: true,
                autoPadding: true
            }
        },
        {
            type: "File",
            options: {
                filename: "logs/moleculer-{date}.log",
                formatter: "json"
            }
        }
    ],

    logLevel: {
        "*": "warn",
        "BROKER": "info",
        "REGISTRY": "info"
    },

    // Metrics
    metrics: {
        enabled: true,
        reporter: [
            {
                type: "Prometheus",
                options: {
                    port: 3030,
                    path: "/metrics",
                    defaultLabels: (registry) => ({
                        namespace: registry.broker.namespace,
                        nodeID: registry.broker.nodeID
                    })
                }
            }
        ]
    },

    // Tracing
    tracing: {
        enabled: true,
        exporter: [
            {
                type: "Console",
                options: {
                    colors: true,
                    width: 100,
                    gaugeWidth: 40
                }
            },
            {
                type: "Jaeger",
                options: {
                    host: process.env.JAEGER_HOST || "localhost",
                    port: 6832,
                    sampler: {
                        type: "Const",
                        options: { decision: 1 }
                    }
                }
            }
        ]
    },

    // Fault tolerance
    requestTimeout: 10 * 1000,

    retryPolicy: {
        enabled: true,
        retries: 3,
        delay: 100,
        maxDelay: 2000,
        factor: 2,
        check: err => err && !!err.retryable
    },

    circuitBreaker: {
        enabled: true,
        threshold: 0.5,
        minRequestCount: 20,
        windowTime: 60,
        halfOpenTime: 10 * 1000,
        check: err => err && err.code >= 500
    },

    bulkhead: {
        enabled: true,
        concurrency: 10,
        maxQueueSize: 100
    },

    // Caching
    cacher: {
        type: "Memory",
        options: {
            ttl: 30
        }
    },

    transporter: null
};

What to Do When Something Goes Wrong in Production

Here is a practical debugging workflow using all three pillars together:

Step 1: Check metrics first. Look at your Grafana dashboard. Which action has a spike in error rate or response time? Suppose you see order.create response time jumped from 50ms to 5000ms.

Step 2: Open Jaeger and filter traces for order.create in the last 30 minutes. Sort by duration. Open the slowest trace.

Step 3: The trace shows order.create called product.get and that took 4900ms. Everything else was normal.

Step 4: Check your logs filtered by the requestID from the slow trace. You see a MongoDB query timeout in the product service.

Step 5: Fix the MongoDB index on the product collection. Response time drops back to normal.

In five steps you went from knowing something is wrong to knowing exactly what and why. Without observability this could take hours.


Summary

  • Observability has three pillars: Logging, Metrics, Tracing.
  • Logging records what happened. Use structured JSON logging in production. Use appropriate log levels.
  • Multiple loggers can run simultaneously — console for development, file for production.
  • logLevel can be set per service for fine-grained control.
  • Metrics track system performance numbers. Moleculer tracks request counts, response times, errors, and memory automatically.
  • Prometheus reporter exposes metrics at an HTTP endpoint. Grafana reads Prometheus to create dashboards.
  • Custom metrics let you track business numbers like total orders and total revenue.
  • Tracing records the complete journey of each request through all services.
  • Each hop in the call chain creates a span. Spans are organized into a trace tree.
  • Jaeger is the best tool for visualizing traces locally.
  • Use ctx.span.setTag() to attach searchable metadata to traces.
  • The debugging workflow: metrics to find the problem, tracing to locate it, logging to understand it.

Up Next

Post 13 covers Docker and Docker Compose — packaging each service into a container and running the entire application with one command. This is the final step before production deployment. We will containerize all our services, add MongoDB and Redis containers, and wire everything together with a transporter.


Course Progress: 12 of 15 posts complete.

No comments:

Post a Comment

Phase 9 - Final Post | Post 15 | What to Learn Next — Testing, Advanced Patterns, Kubernetes, and the Road Ahead

Post 15 of 15 | Final Post What to Learn Next — Testing, Advanced Patterns, Kubernetes, and the Road Ahead You have completed the core Mo...