Node.js में Observability: Logs, Metrics, और Traces बिना Complexity के
Pino से structured logging, Prometheus से metrics, OpenTelemetry से distributed tracing। Node.js production apps के लिए मेरा observability stack, zero से full visibility तक।
मैं सोचता था observability का मतलब है "कुछ console.logs डाल दो और जब कुछ टूटे तब check करो।" यह तब तक काम किया जब तक नहीं किया। Breaking point एक production incident था जहां हमारी API 200s return कर रही थी लेकिन data stale था। Logs में कोई errors नहीं। कोई exceptions नहीं। बस silently गलत responses क्योंकि एक downstream cache stale हो गया था और किसी ने चार घंटे तक notice नहीं किया।
तभी मैंने monitoring और observability के बीच फर्क समझा। Monitoring बताता है कुछ गलत है। Observability बताता है क्यों गलत है। और इन दोनों के बीच का gap वो जगह है जहां production incidents रहती हैं।
यह वो observability stack है जो मैंने Node.js services के लिए ज़्यादातर alternatives try करने के बाद चुना है। यह दुनिया का सबसे sophisticated setup नहीं है, लेकिन यह problems को users के notice करने से पहले पकड़ता है, और जब कुछ slip through होता है तो मैं घंटों की बजाय minutes में diagnose कर सकता हूं।
तीन Pillars, और आपको तीनों क्यों चाहिए#
सब "three pillars of observability" की बात करते हैं — logs, metrics, और traces। जो कोई नहीं बताता वो यह है कि हर pillar एक fundamentally अलग सवाल का जवाब देता है, और आपको तीनों चाहिए क्योंकि कोई एक pillar हर सवाल का जवाब नहीं दे सकता।
Logs जवाब देते हैं: क्या हुआ?
एक log line कहती है "14:23:07 पर, user 4821 ने /api/orders request किया और 500 मिला क्योंकि database connection timeout हो गया।" यह एक narrative है। यह एक specific event की कहानी बताता है।
Metrics जवाब देते हैं: कितना हो रहा है?
एक metric कहता है "पिछले 5 minutes में, p99 response time 2.3 seconds था और error rate 4.7% थी।" यह aggregate data है। यह पूरे system की health के बारे में बताता है, किसी individual request के बारे में नहीं।
Traces जवाब देते हैं: समय कहां गया?
एक trace कहता है "इस request ने 12ms Express middleware में बिताया, 3ms body parse करने में, 847ms PostgreSQL का wait करने में, और 2ms response serialize करने में।" यह एक waterfall है। यह exactly बताता है bottleneck कहां है, service boundaries के पार।
Practical implication यह है: जब रात 3 बजे pager बजता है, sequence लगभग हमेशा यही होता है।
- Metrics बताती हैं कुछ गलत है (error rate spike, latency increase)
- Logs बताते हैं क्या हो रहा है (specific error messages, affected endpoints)
- Traces बताते हैं क्यों (कौन सी downstream service या database query bottleneck है)
अगर सिर्फ logs हैं, तो क्या टूटा पता चलेगा लेकिन कितना बुरा है नहीं। सिर्फ metrics हैं तो कितना बुरा पता चलेगा लेकिन क्या cause कर रहा है नहीं। सिर्फ traces हैं तो beautiful waterfalls होंगे लेकिन कब देखना है पता नहीं चलेगा।
चलो हर एक बनाते हैं।
Pino से Structured Logging#
console.log काफी क्यों नहीं है#
मुझे पता है। आप production में console.log इस्तेमाल कर रहे हैं और "ठीक" है। दिखाता हूं क्यों नहीं है।
// जो आप लिखते हैं
console.log("User login failed", email, error.message);
// जो log file में जाता है
// User login failed john@example.com ECONNREFUSED
// अब try करो:
// 1. पिछले घंटे की सभी login failures search करो
// 2. Per user failures count करो
// 3. सिर्फ ECONNREFUSED errors filter करो
// 4. इसे trigger करने वाली request से correlate करो
// Good luck। यह एक unstructured string है। Text grep कर रहे हो।Structured logging का मतलब है हर log entry एक JSON object है consistent fields के साथ। Human-readable string जो machine-hostile है उसकी बजाय, machine-readable object मिलता है जो human-readable भी है (सही tools के साथ)।
// Structured logging कैसी दिखती है
{
"level": 50,
"time": 1709312587000,
"msg": "User login failed",
"email": "john@example.com",
"error": "ECONNREFUSED",
"requestId": "req-abc-123",
"route": "POST /api/auth/login",
"responseTime": 1247,
"pid": 12345
}अब query कर सकते हैं। level >= 50 AND msg = "User login failed" AND time > now() - 1h exactly वो देता है जो चाहिए।
Pino vs Winston#
दोनों extensively इस्तेमाल किए हैं। Short version:
Winston ज़्यादा popular है, ज़्यादा flexible है, ज़्यादा transports हैं, और significantly slower है। यह bad patterns भी encourage करता है — "format" system बहुत आसान बनाता है unstructured, pretty-printed logs बनाना जो development में अच्छे दिखते हैं लेकिन production में unparseable हैं।
Pino faster है (benchmarks में 5-10x), JSON output के बारे में opinionated है, और Unix philosophy follow करता है: एक काम अच्छे से करो (JSON stdout पर लिखो) और बाकी tools handle करें (transport, formatting, aggregation)।
मैं Pino इस्तेमाल करता हूं। Performance difference matter करता है जब per second हज़ारों requests log कर रहे हो, और opinionated approach का मतलब है team का हर developer consistent logs produce करता है।
Basic Pino Setup#
// src/lib/logger.ts
import pino from "pino";
const isProduction = process.env.NODE_ENV === "production";
export const logger = pino({
level: process.env.LOG_LEVEL || (isProduction ? "info" : "debug"),
// Production में, सिर्फ JSON stdout पर। PM2/container runtime बाकी handle करता है।
// Development में, pino-pretty human-readable output के लिए।
...(isProduction
? {}
: {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss",
ignore: "pid,hostname",
},
},
}),
// हर log line पर standard fields
base: {
service: process.env.SERVICE_NAME || "api",
version: process.env.APP_VERSION || "unknown",
},
// Error objects properly serialize करो
serializers: {
err: pino.stdSerializers.err,
error: pino.stdSerializers.err,
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
},
// Sensitive fields redact करो
redact: {
paths: [
"req.headers.authorization",
"req.headers.cookie",
"password",
"creditCard",
"ssn",
],
censor: "[REDACTED]",
},
});redact option critical है। बिना इसके, आप eventually एक password या API key log करोगे। यह "अगर" का सवाल नहीं, "कब" का है। कोई developer logger.info({ body: req.body }, "incoming request") add कर देगा और अचानक credit card numbers log हो रहे हैं। Redaction आपका safety net है।
Log Levels: सही तरीके से इस्तेमाल करो#
// FATAL (60) - Process crash होने वाला है। किसी को जगाओ।
logger.fatal({ err }, "Unrecoverable database connection failure");
// ERROR (50) - कुछ fail हुआ जो नहीं होना चाहिए था। जल्दी investigate करो।
logger.error({ err, userId, orderId }, "Payment processing failed");
// WARN (40) - कुछ unexpected लेकिन handled। नज़र रखो।
logger.warn({ retryCount: 3, service: "email" }, "Retry limit approaching");
// INFO (30) - Normal operations record करने लायक। "क्या हुआ" log।
logger.info({ userId, action: "login" }, "User authenticated");
// DEBUG (20) - Debugging के लिए detailed information। Production में कभी नहीं।
logger.debug({ query, params }, "Database query executing");
// TRACE (10) - बेहद detailed। सिर्फ जब बहुत ज़रूरी हो।
logger.trace({ headers: req.headers }, "Incoming request headers");Rule: अगर INFO और DEBUG के बीच debate हो रही है, तो DEBUG है। WARN और ERROR के बीच debate है तो खुद से पूछो: "क्या मैं इसके लिए रात 3 बजे alert चाहता हूं?" अगर हां, ERROR। नहीं तो, WARN।
Child Loggers और Request Context#
यहां Pino सच में shine करता है। Child logger parent की सारी configuration inherit करता है लेकिन extra context fields add करता है।
// इस child logger की हर log में userId और sessionId automatically होगा
const userLogger = logger.child({ userId: "usr_4821", sessionId: "ses_xyz" });
userLogger.info("User viewed dashboard");
// Output में userId और sessionId automatically included
userLogger.info({ page: "/settings" }, "User navigated");
// Output में userId, sessionId, और page सब includedHTTP servers के लिए, per request एक child logger चाहिए ताकि उस request lifecycle की हर log line में request ID हो:
// src/middleware/request-logger.ts
import { randomUUID } from "node:crypto";
import { logger } from "../lib/logger";
import type { Request, Response, NextFunction } from "express";
export function requestLogger(req: Request, res: Response, next: NextFunction) {
const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
const startTime = performance.now();
// Request पर child logger attach करो
req.log = logger.child({
requestId,
method: req.method,
url: req.originalUrl,
userAgent: req.headers["user-agent"],
ip: req.headers["x-forwarded-for"]?.toString().split(",").pop()?.trim()
|| req.socket.remoteAddress,
});
// Correlation के लिए response पर request ID header set करो
res.setHeader("x-request-id", requestId);
req.log.info("Request received");
res.on("finish", () => {
const duration = Math.round(performance.now() - startTime);
const logMethod = res.statusCode >= 500 ? "error"
: res.statusCode >= 400 ? "warn"
: "info";
req.log[logMethod]({
statusCode: res.statusCode,
duration,
contentLength: res.getHeader("content-length"),
}, "Request completed");
});
next();
}Automatic Context Propagation के लिए AsyncLocalStorage#
Child logger approach काम करता है, लेकिन req.log हर function call में pass करना पड़ता है। यह tedious हो जाता है। AsyncLocalStorage इसे solve करता है — यह एक context store provide करता है जो async execution flow follow करता है बिना explicit passing के।
// src/lib/async-context.ts
import { AsyncLocalStorage } from "node:async_hooks";
import { logger } from "./logger";
import type { Logger } from "pino";
interface RequestContext {
requestId: string;
logger: Logger;
userId?: string;
startTime: number;
}
export const asyncContext = new AsyncLocalStorage<RequestContext>();
// Call stack में कहीं से भी contextual logger लो
export function getLogger(): Logger {
const store = asyncContext.getStore();
return store?.logger || logger;
}
export function getRequestId(): string | undefined {
return asyncContext.getStore()?.requestId;
}// src/middleware/async-context-middleware.ts
import { randomUUID } from "node:crypto";
import { asyncContext } from "../lib/async-context";
import { logger } from "../lib/logger";
import type { Request, Response, NextFunction } from "express";
export function asyncContextMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
const requestId = req.headers["x-request-id"]?.toString() || randomUUID();
const requestLogger = logger.child({ requestId });
const context = {
requestId,
logger: requestLogger,
startTime: performance.now(),
};
asyncContext.run(context, () => {
res.setHeader("x-request-id", requestId);
next();
});
}अब कोई भी function, call stack में कहीं भी, request-scoped logger ले सकता है:
// src/services/order-service.ts
import { getLogger } from "../lib/async-context";
export async function processOrder(orderId: string) {
const log = getLogger(); // Automatically requestId attached है!
log.info({ orderId }, "Processing order");
const items = await fetchOrderItems(orderId);
log.debug({ itemCount: items.length }, "Order items fetched");
const total = calculateTotal(items);
log.info({ orderId, total }, "Order processed successfully");
return { orderId, total, items };
}
// Logger parameter pass करने की ज़रूरत नहीं। बस काम करता है।Log Aggregation: Logs कहां जाते हैं?#
Development में, logs stdout पर जाते हैं और pino-pretty उन्हें readable बनाता है। Production में, बात ज़्यादा nuanced है।
PM2 Path#
अगर VPS पर PM2 के साथ run कर रहे हैं, PM2 stdout automatically capture करता है:
# Real-time logs देखो
pm2 logs api --lines 100
# Logs यहां stored हैं ~/.pm2/logs/
# api-out.log — stdout (आपके JSON logs)
# api-error.log — stderr (uncaught exceptions, stack traces)PM2 का built-in log rotation disk space issues prevent करता है:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 14
pm2 set pm2-logrotate:compress trueLogs Loki या Elasticsearch को Ship करना#
Single server से आगे कुछ भी हो, centralized log aggregation चाहिए। दो main options:
Grafana Loki — "Prometheus for logs।" Lightweight, सिर्फ labels index करता है (full text नहीं), Grafana के साथ beautifully काम करता है। ज़्यादातर teams के लिए मेरी recommendation।
Elasticsearch — Logs पर full-text search। ज़्यादा powerful, ज़्यादा resource-hungry, ज़्यादा operational overhead। तब इस्तेमाल करें जब genuinely millions of log lines पर full-text search चाहिए।
Loki के लिए, सबसे simple setup Promtail से logs ship करता है:
# /etc/promtail/config.yml
server:
http_listen_port: 9080
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: node-api
static_configs:
- targets:
- localhost
labels:
job: node-api
environment: production
__path__: /home/deploy/.pm2/logs/api-out.log
pipeline_stages:
- json:
expressions:
level: level
msg: msg
service: service
- labels:
level:
service:
- timestamp:
source: time
format: UnixMsNDJSON Format#
Pino default रूप से Newline Delimited JSON (NDJSON) output करता है — per line एक JSON object, \n से separated। यह important है क्योंकि:
- हर log aggregation tool इसे समझता है
- यह streamable है (पूरी file buffer किए बिना line by line process कर सकते हैं)
- Standard Unix tools इस पर काम करते हैं:
cat api-out.log | jq '.msg' | sort | uniq -c | sort -rn
Production में Pino को pretty-printed, multi-line JSON output करने के लिए कभी configure मत करो। Pipeline का हर tool टूट जाएगा।
Prometheus से Metrics#
Logs बताते हैं क्या हुआ। Metrics बताती हैं system कैसा perform कर रहा है। फर्क वैसा है जैसे bank statement की हर transaction पढ़ना versus अपना account balance देखना।
चार Metric Types#
Prometheus में चार metric types हैं। कौन सा कब इस्तेमाल करना है यह समझना आपको सबसे common mistakes से बचाएगा।
Counter — एक value जो सिर्फ बढ़ती है। Request count, error count, bytes processed। Restart पर zero हो जाती है।
// "हमने कितने requests serve किए हैं?"
const httpRequestsTotal = new Counter({
name: "http_requests_total",
help: "Total number of HTTP requests",
labelNames: ["method", "route", "status_code"],
});Gauge — एक value जो ऊपर-नीचे हो सकती है। Current connections, queue size, temperature, heap usage।
// "अभी कितने connections active हैं?"
const activeConnections = new Gauge({
name: "active_connections",
help: "Number of currently active connections",
});Histogram — Values observe करता है और configurable buckets में count करता है। Request duration, response size। इससे percentiles (p50, p95, p99) मिलते हैं।
// "Requests कितना time लेते हैं?" 10ms, 50ms, 100ms आदि पर buckets के साथ
const httpRequestDuration = new Histogram({
name: "http_request_duration_seconds",
help: "Duration of HTTP requests in seconds",
labelNames: ["method", "route", "status_code"],
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});Summary — Histogram जैसा लेकिन client side पर quantiles calculate करता है। Histogram इस्तेमाल करें जब तक कोई specific reason न हो। Summaries instances across aggregate नहीं हो सकतीं।
Full prom-client Setup#
// src/lib/metrics.ts
import {
Registry,
Counter,
Histogram,
Gauge,
collectDefaultMetrics,
} from "prom-client";
// Global pollute करने से बचने के लिए custom registry
export const metricsRegistry = new Registry();
// Default Node.js metrics collect करो:
// - process_cpu_seconds_total
// - process_resident_memory_bytes
// - nodejs_heap_size_total_bytes
// - nodejs_active_handles_total
// - nodejs_eventloop_lag_seconds
// - nodejs_gc_duration_seconds
collectDefaultMetrics({
register: metricsRegistry,
prefix: "nodeapp_",
gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5],
});
// --- HTTP Metrics ---
export const httpRequestsTotal = new Counter({
name: "nodeapp_http_requests_total",
help: "Total number of HTTP requests received",
labelNames: ["method", "route", "status_code"] as const,
registers: [metricsRegistry],
});
export const httpRequestDuration = new Histogram({
name: "nodeapp_http_request_duration_seconds",
help: "Duration of HTTP requests in seconds",
labelNames: ["method", "route", "status_code"] as const,
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [metricsRegistry],
});
// --- Business Metrics ---
export const ordersProcessed = new Counter({
name: "nodeapp_orders_processed_total",
help: "Total number of orders processed",
labelNames: ["status"] as const,
registers: [metricsRegistry],
});
export const activeWebSocketConnections = new Gauge({
name: "nodeapp_active_websocket_connections",
help: "Number of active WebSocket connections",
registers: [metricsRegistry],
});
export const externalApiDuration = new Histogram({
name: "nodeapp_external_api_duration_seconds",
help: "Duration of external API calls",
labelNames: ["service", "endpoint", "status"] as const,
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30],
registers: [metricsRegistry],
});
export const dbQueryDuration = new Histogram({
name: "nodeapp_db_query_duration_seconds",
help: "Duration of database queries",
labelNames: ["operation", "table"] as const,
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5],
registers: [metricsRegistry],
});Metrics Middleware#
// src/middleware/metrics-middleware.ts
import { httpRequestsTotal, httpRequestDuration } from "../lib/metrics";
import type { Request, Response, NextFunction } from "express";
// Cardinality explosion से बचने के लिए routes normalize करो
// /api/users/123 → /api/users/:id
// बिना इसके, Prometheus हर user ID के लिए नई time series बनाएगा
function normalizeRoute(req: Request): string {
const route = req.route?.path || req.path;
return route
.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "/:uuid")
.replace(/\/\d+/g, "/:id")
.replace(/\/[a-f0-9]{24}/g, "/:objectId");
}
export function metricsMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
if (req.path === "/metrics") {
return next();
}
const end = httpRequestDuration.startTimer();
res.on("finish", () => {
const route = normalizeRoute(req);
const labels = {
method: req.method,
route,
status_code: res.statusCode.toString(),
};
httpRequestsTotal.inc(labels);
end(labels);
});
next();
}Custom Business Metrics असली ताकत हैं#
Default Node.js metrics (heap size, event loop lag, GC duration) table stakes हैं। ये runtime health बताती हैं। लेकिन business metrics application health बताती हैं।
// Order service में
import { ordersProcessed, externalApiDuration } from "../lib/metrics";
export async function processOrder(order: Order) {
try {
// Payment provider call time करो
const paymentTimer = externalApiDuration.startTimer({
service: "stripe",
endpoint: "charges.create",
});
const charge = await stripe.charges.create({
amount: order.total,
currency: "usd",
source: order.paymentToken,
});
paymentTimer({ status: "success" });
ordersProcessed.inc({ status: "success" });
return charge;
} catch (err) {
ordersProcessed.inc({ status: "failed" });
throw err;
}
}ordersProcessed{status="failed"} में spike आपको वो बताता है जो कितनी भी CPU metrics कभी नहीं बता सकतीं।
Label Cardinality: Silent Killer#
एक चेतावनी। Label values का हर unique combination एक नई time series बनाता है। अगर HTTP request counter पर userId label add करोगे, और 100,000 users हैं, तो 100,000+ time series बन गईं। Prometheus रुक जाएगा।
Labels के rules:
- सिर्फ low cardinality: HTTP method (7 values), status code (5 categories), route (tens, हज़ारों नहीं)
- कभी user IDs, request IDs, IP addresses, या timestamps label values के रूप में इस्तेमाल मत करो
- Sure नहीं हो तो label मत add करो। बाद में add कर सकते हो, लेकिन remove करने के लिए dashboards और alerts बदलने पड़ते हैं
Grafana Dashboards#
Prometheus data store करता है। Grafana visualize करता है। ये panels मैं हर Node.js service dashboard पर रखता हूं।
Essential Dashboard#
1. Request Rate (requests/second)
rate(nodeapp_http_requests_total[5m])Traffic pattern दिखाता है। Sudden spikes या drops spot करने के लिए useful।
2. Error Rate (%)
100 * (
sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
/
sum(rate(nodeapp_http_requests_total[5m]))
)सबसे important number। अगर 1% से ऊपर जाए, कुछ गलत है।
3. p50 / p95 / p99 Latency
histogram_quantile(0.99,
sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
)p50 typical experience बताता है। p99 worst experience बताता है। अगर p99 p50 का 10x है, tail latency problem है।
4. Event Loop Lag
nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"}100ms से ऊपर जाए तो event loop blocked है। शायद async path में कोई synchronous operation है।
5. Heap Usage
nodeapp_nodejs_heap_size_used_bytes / nodeapp_nodejs_heap_size_total_bytes * 100Steady upward trend देखो — वो memory leak है। GC के दौरान spikes normal हैं।
OpenTelemetry से Distributed Tracing#
Tracing वो pillar है जो ज़्यादातर teams सबसे बाद adopt करती हैं, और जो सबसे पहले adopt करनी चाहिए थी। जब multiple services आपस में बात कर रही हैं (चाहे बस "API server + database + Redis + external API" हो), tracing request की पूरी journey दिखाता है।
Trace क्या है?#
Trace spans का एक tree है। हर span work की एक unit represent करता है — एक HTTP request, एक database query, एक function call। Spans में start time, end time, status, और attributes होते हैं। ये trace ID से linked होते हैं जो service boundaries के पार propagate होता है।
Trace: abc-123
├── [API Gateway] POST /api/orders (250ms)
│ ├── [Auth Service] validate-token (12ms)
│ ├── [Order Service] create-order (230ms)
│ │ ├── [PostgreSQL] INSERT INTO orders (15ms)
│ │ ├── [Redis] SET order:cache (2ms)
│ │ └── [Payment Service] charge (200ms)
│ │ ├── [Stripe API] POST /v1/charges (180ms)
│ │ └── [PostgreSQL] UPDATE orders SET status (8ms)
│ └── [Email Service] send-confirmation (async, 45ms)
एक नज़र में पता: 250ms request ने 180ms Stripe का wait करने में बिताया। Optimize वहां करो।
OpenTelemetry Setup#
OpenTelemetry (OTel) standard है। इसने Jaeger clients, Zipkin clients, और vendor-specific SDKs के fragmented landscape को एक single, vendor-neutral API से replace किया।
// src/instrumentation.ts
// यह file किसी भी other import से पहले load होनी चाहिए।
// Node.js में, --require या --import flag इस्तेमाल करें।
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { Resource } from "@opentelemetry/resources";
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
const sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: process.env.SERVICE_NAME || "node-api",
[ATTR_SERVICE_VERSION]: process.env.APP_VERSION || "0.0.0",
"deployment.environment": process.env.NODE_ENV || "development",
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/metrics",
}),
exportIntervalMillis: 15000,
}),
// Auto-instrumentation: automatically spans बनाता है
// HTTP requests, Express routes, PostgreSQL queries, Redis commands,
// DNS lookups, और बहुत कुछ के लिए
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-fs": { enabled: false },
"@opentelemetry/instrumentation-dns": { enabled: false },
"@opentelemetry/instrumentation-http": {
ignoreIncomingPaths: ["/health", "/ready", "/metrics"],
},
"@opentelemetry/instrumentation-express": {
ignoreLayersType: ["middleware"],
},
}),
],
});
sdk.start();
// Graceful shutdown
process.on("SIGTERM", () => {
sdk.shutdown().then(
() => console.log("OTel SDK shut down successfully"),
(err) => console.error("Error shutting down OTel SDK", err)
);
});App start करो:
node --import ./src/instrumentation.ts ./src/server.tsबस। Application code में zero changes के साथ, अब हर HTTP request, हर database query, हर Redis command के लिए traces मिलते हैं।
Manual Span Creation#
Auto-instrumentation infrastructure calls cover करता है, लेकिन कभी-कभी business logic trace करनी होती है:
// src/services/order-service.ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
const tracer = trace.getTracer("order-service");
export async function processOrder(orderId: string): Promise<Order> {
return tracer.startActiveSpan("processOrder", async (span) => {
try {
span.setAttribute("order.id", orderId);
const order = await fetchOrder(orderId);
span.setAttribute("order.total", order.total);
span.setAttribute("order.item_count", order.items.length);
const validationResult = await tracer.startActiveSpan(
"validateOrder",
async (validationSpan) => {
const result = await validateInventory(order);
validationSpan.setAttribute("validation.passed", result.valid);
if (!result.valid) {
validationSpan.setStatus({
code: SpanStatusCode.ERROR,
message: `Validation failed: ${result.reason}`,
});
}
validationSpan.end();
return result;
}
);
if (!validationResult.valid) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: "Order validation failed",
});
throw new Error(validationResult.reason);
}
const payment = await processPayment(order);
span.setAttribute("payment.id", payment.id);
span.setStatus({ code: SpanStatusCode.OK });
return order;
} catch (err) {
span.recordException(err as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: (err as Error).message,
});
throw err;
} finally {
span.end();
}
});
}Health Check Endpoints#
Health checks observability का सबसे simple form हैं और सबसे पहले implement करनी चाहिए। ये एक सवाल का जवाब देते हैं: "क्या यह service अभी requests serve करने में capable है?"
तीन Types के Health Checks#
/health — General health। Process running और responsive है?
/ready — Readiness। Service traffic handle कर सकती है? (Database से connect हो गई? Configuration load हो गई? Cache warm हो गया?)
/live — Liveness। Process alive है और deadlocked नहीं है? (Simple request timeout के अंदर respond कर सकता है?)
Kubernetes में यह distinction matter करता है, जहां liveness probes stuck containers restart करती हैं और readiness probes startup या dependency failure के दौरान containers को load balancer से remove करती हैं।
// src/routes/health.ts
import { Router } from "express";
import { Pool } from "pg";
import Redis from "ioredis";
const router = Router();
async function checkDatabase(pool: Pool): Promise<{ ok: boolean; latency: number }> {
const start = performance.now();
try {
await pool.query("SELECT 1");
return { ok: true, latency: Math.round(performance.now() - start) };
} catch {
return { ok: false, latency: Math.round(performance.now() - start) };
}
}
async function checkRedis(redis: Redis): Promise<{ ok: boolean; latency: number }> {
const start = performance.now();
try {
await redis.ping();
return { ok: true, latency: Math.round(performance.now() - start) };
} catch {
return { ok: false, latency: Math.round(performance.now() - start) };
}
}
export function createHealthRoutes(pool: Pool, redis: Redis) {
router.get("/live", (_req, res) => {
res.status(200).json({ status: "ok" });
});
router.get("/ready", async (_req, res) => {
const [db, cache] = await Promise.all([
checkDatabase(pool),
checkRedis(redis),
]);
const allOk = db.ok && cache.ok;
res.status(allOk ? 200 : 503).json({
status: allOk ? "ok" : "not_ready",
checks: { database: db, redis: cache },
});
});
router.get("/health", async (_req, res) => {
const [db, cache] = await Promise.all([
checkDatabase(pool),
checkRedis(redis),
]);
const anyError = !db.ok || !cache.ok;
const allError = !db.ok && !cache.ok;
res.status(allError ? 503 : 200).json({
status: allError ? "error" : anyError ? "degraded" : "ok",
checks: {
database: { status: db.ok ? "ok" : "error", latency: db.latency },
redis: { status: cache.ok ? "ok" : "error", latency: cache.latency },
},
uptime: process.uptime(),
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || "unknown",
});
});
return router;
}एक common गलती: liveness probe बहुत aggressive बनाना। अगर liveness probe database check करती है, और database temporarily down है, Kubernetes container restart करेगा। लेकिन restart से database ठीक नहीं होगा। अब database outage के ऊपर crash loop भी है। Liveness probes simple रखो — सिर्फ deadlocked या stuck processes detect करें।
Sentry से Error Tracking#
Logs वो errors पकड़ते हैं जो expected थे। Sentry वो पकड़ता है जो नहीं थे।
फर्क important है। Try/catch blocks उस code के around डालते हैं जो fail हो सकता है। लेकिन सबसे ज़्यादा matter करने वाले bugs उस code में हैं जो safe लगता था। Unhandled promise rejections, unexpected API responses से type errors, optional chains पर null pointer access जो इतनी optional नहीं थीं।
Alerting: वो Alerts जो असल में Matter करती हैं#
200 Prometheus metrics और zero alerts बस vanity monitoring है। 50 alerts जो रोज़ fire हों, alert fatigue है — ignore करना शुरू करोगे, और फिर वो एक alert miss हो जाएगी जो matter करती है।
Goal: कम high-signal alerts जिनका मतलब हो "कुछ genuinely गलत है और एक human को देखना चाहिए।"
हर alert इस test से गुज़रनी चाहिए:
- Actionable है? अगर कोई कुछ कर नहीं सकता, alert मत करो। Log करो, dashboard पर रखो, लेकिन किसी को मत जगाओ।
- Human intervention ज़रूरी है? अगर खुद ठीक हो जाती है (जैसे brief network blip),
forclause filter कर देगी। - पिछले 30 दिनों में fire हुई? अगर नहीं, शायद misconfigured है या threshold गलत है। Review करो।
- Fire होने पर लोग care करते हैं? अगर team regularly dismiss करती है, remove करो या threshold fix करो।
Minimal Viable Stack#
ऊपर सब कुछ "full" stack है। Day one पर सब कुछ ज़रूरी नहीं। जैसे project बढ़े, observability scale करो।
Stage 1: Side Project / Solo Developer#
तीन चीज़ें चाहिए:
-
Structured console logs — Pino इस्तेमाल करो, JSON stdout पर output करो। चाहे बस
pm2 logsसे पढ़ रहे हो, JSON logs searchable और parseable हैं। -
एक /health endpoint — Implement करने में 5 minutes लगते हैं, "run भी हो रहा है?" debug करते समय बचाता है।
-
Sentry free tier — Anticipated नहीं किए गए errors पकड़ता है। Free tier 5,000 events/month देता है, side project के लिए काफी है।
Stage 2: Growing Project / Small Team#
Add करो:
-
Prometheus metrics + Grafana — "Slow लग रहा है" काफी नहीं और data चाहिए। Request rate, error rate, और p99 latency से शुरू करो।
-
Log aggregation — जब server में SSH करके files grep करना scale नहीं करता। Loki + Promtail अगर पहले से Grafana इस्तेमाल कर रहे हो।
-
Basic alerts — Error rate > 1%, p99 > 1s, service down। तीन alerts। बस।
Stage 3: Production Service / Multiple Services#
Add करो:
-
OpenTelemetry से distributed tracing — जब "API slow है" बन जाए "5 services में से कौन सी slow है?" OTel auto-instrumentation zero code changes के साथ 80% value देता है।
-
Dashboard as code — Grafana dashboards version-control करो।
-
Business metrics — Orders/second, conversion rate, queue depth। वो metrics जो product team care करती है।
क्या Skip करें#
- Per-host pricing वाले APM vendors — Scale पर cost absurd है। Open source (Prometheus + Grafana + Tempo + Loki) 95% functionality देता है।
- Production में INFO से नीचे log levels — Terabytes DEBUG logs generate होंगी और storage pay करनी पड़ेगी।
- हर चीज़ के लिए custom metrics — RED method (Rate, Errors, Duration) से शुरू करो हर service के लिए।
Final Thoughts#
Observability कोई product नहीं जो खरीदते हैं या tool जो install करते हैं। यह एक practice है। यह अपनी service operate करने और hope करने कि service खुद operate करे, इसके बीच का फर्क है।
Stack जो मैंने describe किया — Pino logs के लिए, Prometheus metrics के लिए, OpenTelemetry traces के लिए, Sentry errors के लिए, Grafana visualization के लिए, AlertManager alerts के लिए — सबसे simple setup नहीं है। लेकिन हर piece अपनी जगह earn करता है एक ऐसे सवाल का जवाब देकर जो बाकी pieces नहीं दे सकते।
Structured logs और health endpoint से शुरू करो। "कितना बुरा है" जानना हो तो metrics add करो। "Time कहां जा रहा है" जानना हो तो traces add करो। हर layer पिछली पर build होती है, और किसी के लिए भी application rewrite करने की ज़रूरत नहीं।
Observability add करने का सबसे अच्छा time आपके last production incident से पहले था। दूसरा सबसे अच्छा time अभी है।