This is the implementation guide to go with my earlier post on why observability matters in microservices. That post covered the concepts. This one covers the code.
By the end of this guide you will have:
- A full local observability stack running in Docker (Prometheus, Loki, Tempo, Grafana, OpenTelemetry Collector)
- A Node.js microservice instrumented with traces, metrics, and structured logs
- All three signals correlated in Grafana so you can jump from a metric spike → trace → log in three clicks
- Prometheus alert rules for the RED method (Rate, Errors, Duration)
Every code block here is something you can copy, run, and see working. Let's build it.
The Stack We're Building
Step 1 — Spin Up the Observability Stack with Docker Compose
Create a new directory for the infrastructure config and add the following files.
docker-compose.yml
version: "3.9"
services:
# ── OpenTelemetry Collector ──────────────────────────────────
otel-collector:
image: otel/opentelemetry-collector-contrib:0.99.0
command: ["--config=/etc/otel/config.yaml"]
volumes:
- ./otel/collector-config.yaml:/etc/otel/config.yaml
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
- "8888:8888" # Collector metrics (Prometheus scrape)
depends_on:
- prometheus
- loki
- tempo
# ── Prometheus ───────────────────────────────────────────────
prometheus:
image: prom/prometheus:v2.51.2
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- ./prometheus/rules:/etc/prometheus/rules
- prometheus_data:/prometheus
ports:
- "9090:9090"
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--web.enable-lifecycle"
# ── Loki (log storage) ───────────────────────────────────────
loki:
image: grafana/loki:3.0.0
ports:
- "3100:3100"
volumes:
- ./loki/loki-config.yaml:/etc/loki/config.yaml
- loki_data:/loki
command: -config.file=/etc/loki/config.yaml
# ── Tempo (trace storage) ────────────────────────────────────
tempo:
image: grafana/tempo:2.4.1
ports:
- "3200:3200" # Tempo HTTP
- "4327:4317" # OTLP gRPC (offset to avoid clash with collector)
volumes:
- ./tempo/tempo-config.yaml:/etc/tempo/config.yaml
- tempo_data:/var/tempo
command: -config.file=/etc/tempo/config.yaml
# ── Grafana ───────────────────────────────────────────────────
grafana:
image: grafana/grafana:10.4.2
ports:
- "3030:3000"
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning
- grafana_data:/var/lib/grafana
depends_on:
- prometheus
- loki
- tempo
volumes:
prometheus_data:
loki_data:
tempo_data:
grafana_data:
otel/collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 512
memory_limiter:
check_interval: 1s
limit_mib: 512
# Add service.name as a label on all telemetry
resource:
attributes:
- key: environment
value: local
action: upsert
exporters:
# Metrics → Prometheus (collector exposes a scrape endpoint)
prometheus:
endpoint: "0.0.0.0:8889"
namespace: app
# Logs → Loki
loki:
endpoint: http://loki:3100/loki/api/v1/push
default_labels_enabled:
exporter: false
job: true
labels:
resource:
service.name: "service_name"
environment: "environment"
# Traces → Tempo
otlp/tempo:
endpoint: http://tempo:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [prometheus]
logs:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [loki]
prometheus/prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- "rules/*.yml"
scrape_configs:
# Scrape the OTel Collector's Prometheus exporter
- job_name: "otel-collector"
static_configs:
- targets: ["otel-collector:8889"]
# Scrape Prometheus itself
- job_name: "prometheus"
static_configs:
- targets: ["localhost:9090"]
loki/loki-config.yaml
auth_enabled: false
server:
http_listen_port: 3100
ingester:
lifecycler:
ring:
kvstore:
store: inmemory
replication_factor: 1
schema_config:
configs:
- from: 2024-01-01
store: boltdb-shipper
object_store: filesystem
schema: v11
index:
prefix: index_
period: 24h
storage_config:
boltdb_shipper:
active_index_directory: /loki/index
cache_location: /loki/cache
filesystem:
directory: /loki/chunks
limits_config:
reject_old_samples: true
reject_old_samples_max_age: 168h
tempo/tempo-config.yaml
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
ingester:
trace_idle_period: 10s
max_block_bytes: 1_000_000
max_block_duration: 5m
compactor:
compaction:
block_retention: 48h
storage:
trace:
backend: local
local:
path: /var/tempo/traces
wal:
path: /var/tempo/wal
grafana/provisioning/datasources/datasources.yaml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
url: http://prometheus:9090
isDefault: true
jsonData:
exemplarTraceIdDestinations:
- name: trace_id
datasourceUid: tempo
- name: Loki
type: loki
url: http://loki:3100
jsonData:
derivedFields:
- name: trace_id
matcherRegex: '"trace_id":"(\w+)"'
url: "${__value.raw}"
datasourceUid: tempo
- name: Tempo
type: tempo
uid: tempo
url: http://tempo:3200
jsonData:
nodeGraph:
enabled: true
serviceMap:
datasourceUid: prometheus
lokiSearch:
datasourceUid: loki
Now start everything:
docker compose up -d
Open Grafana at http://localhost:3030 — you'll see all three data sources connected. No data yet; that comes in the next steps.
Step 2 — Instrument a Node.js Service with OpenTelemetry
Create your Node.js service (TypeScript). Start with the dependencies:
npm init -y
npm install express pino pino-http
npm install @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-grpc \
@opentelemetry/exporter-metrics-otlp-grpc \
@opentelemetry/exporter-logs-otlp-grpc \
@opentelemetry/sdk-logs \
@opentelemetry/api \
@opentelemetry/api-logs \
@opentelemetry/resources \
@opentelemetry/semantic-conventions
npm install -D typescript ts-node @types/express @types/node
src/instrumentation.ts — Load This First, Before Everything Else
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-grpc";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-grpc";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { Resource } from "@opentelemetry/resources";
import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
const OTEL_ENDPOINT = process.env.OTEL_ENDPOINT ?? "http://localhost:4317";
const resource = new Resource({
[SEMRESATTRS_SERVICE_NAME]: process.env.SERVICE_NAME ?? "order-service",
[SEMRESATTRS_SERVICE_VERSION]: process.env.SERVICE_VERSION ?? "1.0.0",
environment: process.env.NODE_ENV ?? "development",
});
const sdk = new NodeSDK({
resource,
// Traces
traceExporter: new OTLPTraceExporter({ url: OTEL_ENDPOINT }),
// Metrics — export every 15 seconds
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: OTEL_ENDPOINT }),
exportIntervalMillis: 15_000,
}),
// Logs
logRecordProcessors: [
new BatchLogRecordProcessor(new OTLPLogExporter({ url: OTEL_ENDPOINT })),
],
// Auto-instrument: http, express, pg, redis, fetch, etc.
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-fs": { enabled: false }, // too noisy
}),
],
});
sdk.start();
console.log("OpenTelemetry SDK started");
process.on("SIGTERM", () => sdk.shutdown());
process.on("SIGINT", () => sdk.shutdown());
Critical: this file must be the first thing Node loads. Add it via --require in your start script: