المراقبة في Node.js: السجلات والمقاييس والتتبع بدون التعقيد
تسجيل مُهيكل مع Pino، ومقاييس مع Prometheus، وتتبع موزّع مع OpenTelemetry. حزمة المراقبة التي أستخدمها لتطبيقات Node.js الإنتاجية، من الصفر إلى الرؤية الكاملة.
كنت أظن أن المراقبة تعني "أضف بعض console.log وتحقق منها عندما ينكسر شيء." نجح ذلك حتى لم يعد كذلك. نقطة الانكسار كانت حادثة إنتاجية حيث كان الـ API يُعيد 200 لكن البيانات كانت قديمة. لا أخطاء في السجلات. لا استثناءات. فقط استجابات خاطئة بصمت لأن ذاكرة تخزين مؤقت في خدمة تابعة أصبحت قديمة ولم يلاحظ أحد لمدة أربع ساعات.
عندها تعلمت الفرق بين المراقبة وقابلية الملاحظة. المراقبة تخبرك أن شيئاً خاطئ. قابلية الملاحظة تخبرك لماذا هو خاطئ. والفجوة بين الاثنين هي حيث تعيش حوادث الإنتاج.
هذه هي حزمة المراقبة التي استقررت عليها لخدمات Node.js بعد تجربة معظم البدائل. ليست الإعداد الأكثر تطوراً في العالم، لكنها تلتقط المشاكل قبل أن يلاحظها المستخدمون، وعندما يفلت شيء، أستطيع تشخيصه في دقائق بدلاً من ساعات.
الأعمدة الثلاثة، ولماذا تحتاج جميعها#
الجميع يتحدث عن "الأعمدة الثلاثة لقابلية الملاحظة" — السجلات والمقاييس والتتبعات. ما لا يخبرك به أحد هو أن كل عمود يجيب على سؤال مختلف جوهرياً، وتحتاج الثلاثة لأنه لا يوجد عمود واحد يمكنه الإجابة على كل سؤال.
السجلات تجيب: ماذا حدث؟
سطر السجل يقول "في الساعة 14:23:07، المستخدم 4821 طلب /api/orders وحصل على 500 لأن اتصال قاعدة البيانات انتهت مهلته." إنه سرد. يخبرك قصة حدث واحد محدد.
المقاييس تجيب: كم يحدث؟
المقياس يقول "خلال الدقائق الخمس الأخيرة، كان زمن الاستجابة p99 هو 2.3 ثانية ومعدل الخطأ كان 4.7%." إنه بيانات مجمّعة. يخبرك عن صحة النظام ككل، وليس عن أي طلب فردي.
التتبعات تجيب: أين ذهب الوقت؟
التتبع يقول "هذا الطلب قضى 12 مللي ثانية في وسيط Express، و3 مللي ثانية في تحليل الجسم، و847 مللي ثانية في انتظار PostgreSQL، و2 مللي ثانية في تسلسل الاستجابة." إنه شلال. يخبرك بالضبط أين عنق الزجاجة، عبر حدود الخدمات.
إليك الأثر العملي: عندما يرنّ منبّهك في الساعة 3 فجراً، التسلسل يكون دائماً تقريباً نفسه.
- المقاييس تخبرك أن شيئاً خاطئ (ارتفاع معدل الأخطاء، زيادة زمن الاستجابة)
- السجلات تخبرك بما يحدث (رسائل خطأ محددة، نقاط النهاية المتأثرة)
- التتبعات تخبرك بالسبب (أي خدمة تابعة أو استعلام قاعدة بيانات هو عنق الزجاجة)
إذا كان لديك سجلات فقط، ستعرف ماذا انكسر لكن ليس كم هو سيئ. إذا كان لديك مقاييس فقط، ستعرف كم هو سيئ لكن ليس ما الذي يسببه. إذا كان لديك تتبعات فقط، ستحصل على شلالات جميلة لكن لا طريقة لمعرفة متى تنظر إليها.
لنبنِ كل واحد منها.
التسجيل المُهيكل مع Pino#
لماذا console.log ليس كافياً#
أعلم. كنت تستخدم console.log في الإنتاج وهو "جيد." دعني أريك لماذا ليس كذلك.
// What you write
console.log("User login failed", email, error.message);
// What ends up in your log file
// User login failed john@example.com ECONNREFUSED
// Now try to:
// 1. Search for all login failures in the last hour
// 2. Count failures per user
// 3. Filter out just the ECONNREFUSED errors
// 4. Correlate this with the request that triggered it
// Good luck. It's an unstructured string. You're grepping through text.التسجيل المُهيكل يعني أن كل إدخال سجل هو كائن JSON بحقول متسقة. بدلاً من سلسلة نصية مقروءة للبشر لكنها معادية للآلات، تحصل على كائن مقروء للآلات وأيضاً مقروء للبشر (مع الأدوات الصحيحة).
// What structured logging looks like
{
"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
}الآن يمكنك الاستعلام عن هذا. level >= 50 AND msg = "User login failed" AND time > now() - 1h يعطيك بالضبط ما تحتاجه.
Pino مقابل Winston#
استخدمت كليهما بشكل مكثف. إليك النسخة المختصرة:
Winston أكثر شعبية، أكثر مرونة، لديه ناقلات أكثر، وأبطأ بشكل ملحوظ. كما يشجع على أنماط سيئة — نظام "التنسيق" يجعل من السهل جداً إنشاء سجلات غير مُهيكلة ومنسّقة بشكل جميل تبدو لطيفة في التطوير لكنها غير قابلة للتحليل في الإنتاج.
Pino أسرع (5-10 أضعاف في المعايير)، له رأي حازم حول مخرجات JSON، ويتبع فلسفة Unix: افعل شيئاً واحداً جيداً (اكتب JSON إلى stdout) ودع الأدوات الأخرى تتعامل مع الباقي (النقل، التنسيق، التجميع).
أستخدم Pino. فرق الأداء مهم عندما تسجّل آلاف الطلبات في الثانية، والنهج ذو الرأي الحازم يعني أن كل مطور في الفريق ينتج سجلات متسقة.
إعداد Pino الأساسي#
// 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"),
// In production, just JSON to stdout. PM2/container runtime handles the rest.
// In development, use pino-pretty for human-readable output.
...(isProduction
? {}
: {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss",
ignore: "pid,hostname",
},
},
}),
// Standard fields on every log line
base: {
service: process.env.SERVICE_NAME || "api",
version: process.env.APP_VERSION || "unknown",
},
// Serialize Error objects properly
serializers: {
err: pino.stdSerializers.err,
error: pino.stdSerializers.err,
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
},
// Redact sensitive fields
redact: {
paths: [
"req.headers.authorization",
"req.headers.cookie",
"password",
"creditCard",
"ssn",
],
censor: "[REDACTED]",
},
});خيار redact حاسم. بدونه، ستسجّل في النهاية كلمة مرور أو مفتاح API. ليست مسألة إذا، بل متى. بعض المطورين سيضيف logger.info({ body: req.body }, "incoming request") وفجأة أنت تسجّل أرقام بطاقات الائتمان. التنقيح هو شبكة أمانك.
مستويات السجل: استخدمها بشكل صحيح#
// FATAL (60) - The process is about to crash. Wake someone up.
logger.fatal({ err }, "Unrecoverable database connection failure");
// ERROR (50) - Something failed that shouldn't have. Investigate soon.
logger.error({ err, userId, orderId }, "Payment processing failed");
// WARN (40) - Something unexpected but handled. Keep an eye on it.
logger.warn({ retryCount: 3, service: "email" }, "Retry limit approaching");
// INFO (30) - Normal operations worth recording. The "what happened" log.
logger.info({ userId, action: "login" }, "User authenticated");
// DEBUG (20) - Detailed information for debugging. Never in production.
logger.debug({ query, params }, "Database query executing");
// TRACE (10) - Extremely detailed. Only when you're desperate.
logger.trace({ headers: req.headers }, "Incoming request headers");القاعدة: إذا كنت تتردد بين INFO وDEBUG، فهو DEBUG. إذا كنت تتردد بين WARN وERROR، اسأل نفسك: "هل أريد أن أُنبَّه عن هذا في الساعة 3 فجراً؟" إذا نعم، ERROR. إذا لا، WARN.
المُسجّلات الأبناء وسياق الطلب#
هنا يتألق Pino حقاً. المُسجّل الابن يرث كل إعدادات الأب لكن يضيف حقول سياق إضافية.
// Every log from this child logger will include userId and sessionId
const userLogger = logger.child({ userId: "usr_4821", sessionId: "ses_xyz" });
userLogger.info("User viewed dashboard");
// Output includes userId and sessionId automatically
userLogger.info({ page: "/settings" }, "User navigated");
// Output includes userId, sessionId, AND pageلخوادم HTTP، تريد مُسجّلاً ابناً لكل طلب حتى يتضمن كل سطر سجل في دورة حياة ذلك الطلب معرّف الطلب:
// 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();
// Attach a child logger to the request
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,
});
// Set the request ID header on the response for correlation
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();
}AsyncLocalStorage لنشر السياق التلقائي#
نهج المُسجّل الابن يعمل، لكنه يتطلب تمرير req.log عبر كل استدعاء دالة. هذا يصبح مملاً. AsyncLocalStorage يحل هذا — يوفر مخزن سياق يتبع تدفق التنفيذ غير المتزامن بدون تمرير صريح.
// 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>();
// Get the contextual logger from anywhere in the call stack
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();
});
}الآن أي دالة، في أي مكان في مكدّس الاستدعاء، يمكنها الحصول على المُسجّل المحدد بنطاق الطلب:
// src/services/order-service.ts
import { getLogger } from "../lib/async-context";
export async function processOrder(orderId: string) {
const log = getLogger(); // Automatically has 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 };
}
// No need to pass logger as parameter. It just works.تجميع السجلات: أين تذهب السجلات؟#
في التطوير، تذهب السجلات إلى stdout وpino-pretty يجعلها مقروءة. في الإنتاج، الأمر أكثر دقة.
مسار PM2#
إذا كنت تعمل على VPS مع PM2 (الذي تناولته في مقالة إعداد VPS)، PM2 يلتقط stdout تلقائياً:
# View logs in real-time
pm2 logs api --lines 100
# Logs are stored at ~/.pm2/logs/
# api-out.log — stdout (your JSON logs)
# api-error.log — stderr (uncaught exceptions, stack traces)تدوير السجلات المدمج في PM2 يمنع مشاكل مساحة القرص:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 14
pm2 set pm2-logrotate:compress trueشحن السجلات إلى Loki أو Elasticsearch#
لأي شيء يتجاوز خادماً واحداً، تحتاج تجميع سجلات مركزي. الخياران الرئيسيان:
Grafana Loki — "Prometheus للسجلات." خفيف الوزن، يفهرس العلامات فقط (وليس النص الكامل)، يعمل بشكل رائع مع Grafana. توصيتي لمعظم الفرق.
Elasticsearch — بحث نصي كامل على السجلات. أقوى، أكثر استهلاكاً للموارد، عبء تشغيلي أكبر. استخدم هذا إذا كنت تحتاج فعلاً للبحث النصي الكامل عبر ملايين أسطر السجلات.
لـ Loki، أبسط إعداد يستخدم Promtail لشحن السجلات:
# /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: UnixMsتنسيق NDJSON#
Pino يُخرج JSON مفصول بأسطر جديدة (NDJSON) بشكل افتراضي — كائن JSON واحد لكل سطر، مفصول بـ \n. هذا مهم لأن:
- كل أداة تجميع سجلات تفهمه
- يمكن بثّه (يمكنك معالجة السجلات سطراً بسطر بدون تخزين الملف بأكمله مؤقتاً)
- أدوات Unix القياسية تعمل عليه:
cat api-out.log | jq '.msg' | sort | uniq -c | sort -rn
لا تضبط Pino أبداً ليُخرج JSON منسّقاً ومتعدد الأسطر في الإنتاج. ستكسر كل أداة في خط الأنابيب.
// WRONG in production — multi-line JSON breaks line-based processing
{
"level": 30,
"time": 1709312587000,
"msg": "Request completed"
}
// RIGHT in production — NDJSON, one object per line
{"level":30,"time":1709312587000,"msg":"Request completed"}المقاييس مع Prometheus#
السجلات تخبرك بما حدث. المقاييس تخبرك كيف يؤدي النظام. الفرق مثل الفرق بين قراءة كل معاملة في كشف حسابك البنكي مقابل النظر إلى رصيد حسابك.
أنواع المقاييس الأربعة#
لدى Prometheus أربعة أنواع من المقاييس. فهم أيها تستخدم ومتى سيوفر عليك الأخطاء الأكثر شيوعاً.
العدّاد (Counter) — قيمة تتزايد فقط. عدد الطلبات، عدد الأخطاء، البايتات المعالجة. تُعاد إلى الصفر عند إعادة التشغيل.
// "How many requests have we served?"
const httpRequestsTotal = new Counter({
name: "http_requests_total",
help: "Total number of HTTP requests",
labelNames: ["method", "route", "status_code"],
});المقياس (Gauge) — قيمة يمكن أن ترتفع أو تنخفض. الاتصالات الحالية، حجم قائمة الانتظار، درجة الحرارة، استخدام الذاكرة.
// "How many connections are active right now?"
const activeConnections = new Gauge({
name: "active_connections",
help: "Number of currently active connections",
});المخطط البياني (Histogram) — يراقب القيم ويحصيها في حاويات قابلة للتكوين. مدة الطلب، حجم الاستجابة. هكذا تحصل على المئينات (p50، p95، p99).
// "How long do requests take?" with buckets at 10ms, 50ms, 100ms, etc.
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) — مشابه للمخطط البياني لكن يحسب الكميات على جانب العميل. استخدم المخطط البياني بدلاً منه إلا إذا كان لديك سبب محدد لعدم ذلك. الملخصات لا يمكن تجميعها عبر المثيلات.
إعداد prom-client الكامل#
// src/lib/metrics.ts
import {
Registry,
Counter,
Histogram,
Gauge,
collectDefaultMetrics,
} from "prom-client";
// Create a custom registry to avoid polluting the global one
export const metricsRegistry = new Registry();
// Collect default Node.js metrics:
// - 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_",
// Collect every 10 seconds
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],
});
export const httpRequestSizeBytes = new Histogram({
name: "nodeapp_http_request_size_bytes",
help: "Size of HTTP request bodies in bytes",
labelNames: ["method", "route"] as const,
buckets: [100, 1000, 10000, 100000, 1000000],
registers: [metricsRegistry],
});
// --- Business Metrics ---
export const ordersProcessed = new Counter({
name: "nodeapp_orders_processed_total",
help: "Total number of orders processed",
labelNames: ["status"] as const, // "success", "failed", "refunded"
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],
});وسيط المقاييس#
// src/middleware/metrics-middleware.ts
import { httpRequestsTotal, httpRequestDuration } from "../lib/metrics";
import type { Request, Response, NextFunction } from "express";
// Normalize routes to avoid cardinality explosion
// /api/users/123 → /api/users/:id
// Without this, Prometheus will create a new time series for every user ID
function normalizeRoute(req: Request): string {
const route = req.route?.path || req.path;
// Replace common dynamic segments
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
) {
// Don't track metrics for the metrics endpoint itself
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();
}نقطة النهاية /metrics#
// src/routes/metrics.ts
import { Router } from "express";
import { metricsRegistry } from "../lib/metrics";
const router = Router();
router.get("/metrics", async (req, res) => {
// Basic auth protection — don't expose metrics publicly
const authHeader = req.headers.authorization;
const expected = `Basic ${Buffer.from(
`${process.env.METRICS_USER}:${process.env.METRICS_PASSWORD}`
).toString("base64")}`;
if (!authHeader || authHeader !== expected) {
res.status(401).set("WWW-Authenticate", "Basic").send("Unauthorized");
return;
}
try {
const metrics = await metricsRegistry.metrics();
res.set("Content-Type", metricsRegistry.contentType);
res.send(metrics);
} catch (err) {
res.status(500).send("Error collecting metrics");
}
});
export default router;مقاييس الأعمال المخصصة هي القوة الحقيقية#
مقاييس Node.js الافتراضية (حجم الذاكرة، تأخر حلقة الأحداث، مدة GC) هي الحد الأدنى. تخبرك عن صحة بيئة التشغيل. لكن مقاييس الأعمال تخبرك عن صحة التطبيق.
// In your order service
import { ordersProcessed, externalApiDuration } from "../lib/metrics";
export async function processOrder(order: Order) {
try {
// Time the payment provider call
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" });
externalApiDuration.startTimer({
service: "stripe",
endpoint: "charges.create",
})({ status: "error" });
throw err;
}
}ارتفاع في ordersProcessed{status="failed"} يخبرك بشيء لا يمكن لأي كمية من مقاييس المعالج أن تخبرك به.
أصل العلامات: القاتل الصامت#
كلمة تحذير. كل مجموعة فريدة من قيم العلامات تنشئ سلسلة زمنية جديدة. إذا أضفت علامة userId إلى عدّاد طلبات HTTP، ولديك 100,000 مستخدم، فقد أنشأت 100,000+ سلسلة زمنية. Prometheus سيتوقف.
قواعد العلامات:
- أصل منخفض فقط: طريقة HTTP (7 قيم)، رمز الحالة (5 فئات)، المسار (عشرات، وليس آلاف)
- لا تستخدم أبداً معرّفات المستخدمين، أو معرّفات الطلبات، أو عناوين IP، أو الطوابع الزمنية كقيم علامات
- إذا لم تكن متأكداً، لا تضف العلامة. يمكنك دائماً إضافتها لاحقاً، لكن إزالتها تتطلب تغيير لوحات المعلومات والتنبيهات
لوحات معلومات Grafana#
Prometheus يخزّن البيانات. Grafana يعرضها بصرياً. إليك اللوحات التي أضعها على كل لوحة معلومات لخدمة Node.js.
لوحة المعلومات الأساسية#
1. معدل الطلبات (طلبات/ثانية)
rate(nodeapp_http_requests_total[5m])يُظهر نمط الحركة. مفيد لرصد الارتفاعات أو الانخفاضات المفاجئة.
2. معدل الأخطاء (%)
100 * (
sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
/
sum(rate(nodeapp_http_requests_total[5m]))
)الرقم الأكثر أهمية. إذا تجاوز 1%، فشيء خاطئ.
3. زمن الاستجابة p50 / p95 / p99
histogram_quantile(0.99,
sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
)p50 يخبرك بالتجربة النموذجية. p99 يخبرك بأسوأ تجربة. إذا كان p99 أكبر 10 مرات من p50، لديك مشكلة زمن استجابة طرفي.
4. تأخر حلقة الأحداث
nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"}إذا تجاوز 100 مللي ثانية، حلقة الأحداث محجوبة. غالباً عملية متزامنة في مسار غير متزامن.
5. استخدام الذاكرة
nodeapp_nodejs_heap_size_used_bytes / nodeapp_nodejs_heap_size_total_bytes * 100راقب اتجاهاً تصاعدياً ثابتاً — هذا تسرب ذاكرة. الارتفاعات أثناء GC طبيعية.
6. المقابض النشطة
nodeapp_nodejs_active_handles_totalواصفات الملفات المفتوحة، المقابس، المؤقّتات. رقم يتزايد باستمرار يعني أنك تسرّب مقابض — غالباً عدم إغلاق اتصالات قاعدة البيانات أو استجابات HTTP.
لوحة معلومات Grafana كشفرة#
يمكنك التحكم في إصدار لوحات المعلومات باستخدام ميزة التزويد في Grafana:
# /etc/grafana/provisioning/dashboards/dashboards.yml
apiVersion: 1
providers:
- name: "Node.js Services"
orgId: 1
folder: "Services"
type: file
disableDeletion: false
editable: true
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: trueصدّر JSON لوحة المعلومات من Grafana، التزمه في مستودعك، ولوحة معلوماتك تنجو من إعادة تثبيت Grafana. هذا ليس اختيارياً للإنتاج — إنه نفس مبدأ البنية التحتية كشفرة.
التتبع الموزّع مع OpenTelemetry#
التتبع هو العمود الذي تتبناه معظم الفرق أخيراً، والعمود الذي يتمنون لو تبنّوه أولاً. عندما يكون لديك خدمات متعددة تتحدث مع بعضها (حتى لو كانت مجرد "خادم API + قاعدة بيانات + Redis + API خارجي")، التتبع يُظهر لك الصورة الكاملة لرحلة الطلب.
ما هو التتبع؟#
التتبع هو شجرة من النطاقات. كل نطاق يمثل وحدة عمل — طلب HTTP، استعلام قاعدة بيانات، استدعاء دالة. للنطاقات وقت بداية، ووقت نهاية، وحالة، وسمات. وهي مرتبطة ببعضها بواسطة معرّف تتبع يُنشر عبر حدود الخدمات.
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)
نظرة واحدة تخبرك: الطلب الذي استغرق 250 مللي ثانية قضى 180 مللي ثانية في انتظار Stripe. هذا هو المكان الذي تحسّن فيه.
إعداد OpenTelemetry#
OpenTelemetry (OTel) هو المعيار. حلّ محل المشهد المجزأ من عملاء Jaeger وعملاء Zipkin وحزم SDK الخاصة بالموردين بواجهة برمجة واحدة محايدة تجاه الموردين.
// src/instrumentation.ts
// This file MUST be loaded before any other imports.
// In Node.js, use --require or --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",
}),
// Send traces to your collector (Jaeger, Tempo, etc.)
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
}),
// Optionally send metrics through OTel too
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/metrics",
}),
exportIntervalMillis: 15000,
}),
// Auto-instrumentation: automatically creates spans for
// HTTP requests, Express routes, PostgreSQL queries, Redis commands,
// DNS lookups, and more
instrumentations: [
getNodeAutoInstrumentations({
// Disable noisy instrumentations
"@opentelemetry/instrumentation-fs": { enabled: false },
"@opentelemetry/instrumentation-dns": { enabled: false },
// Configure specific ones
"@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)
);
});شغّل تطبيقك بـ:
node --import ./src/instrumentation.ts ./src/server.tsهذا كل شيء. بدون أي تغييرات على كود تطبيقك، تحصل الآن على تتبعات لكل طلب HTTP، وكل استعلام قاعدة بيانات، وكل أمر Redis.
إنشاء النطاقات يدوياً#
الأدوات التلقائية تغطي استدعاءات البنية التحتية، لكن أحياناً تريد تتبع منطق الأعمال:
// 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);
// This span becomes the parent of any auto-instrumented
// DB queries or HTTP calls inside these functions
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();
}
});
}نشر سياق التتبع#
سحر التتبع الموزّع هو أن معرّف التتبع يتبع الطلب عبر الخدمات. عندما تستدعي الخدمة أ الخدمة ب، يتم حقن سياق التتبع تلقائياً في ترويسات HTTP (ترويسة traceparent وفق معيار W3C Trace Context).
الأدوات التلقائية تتعامل مع هذا لاستدعاءات HTTP الصادرة. لكن إذا كنت تستخدم قائمة رسائل، تحتاج للنشر يدوياً:
import { context, propagation } from "@opentelemetry/api";
// When publishing a message
function publishEvent(queue: string, payload: object) {
const carrier: Record<string, string> = {};
// Inject current trace context into the carrier
propagation.inject(context.active(), carrier);
// Send both the payload and the trace context
messageQueue.publish(queue, {
payload,
traceContext: carrier,
});
}
// When consuming a message
function consumeEvent(message: QueueMessage) {
// Extract the trace context from the message
const parentContext = propagation.extract(
context.active(),
message.traceContext
);
// Run the handler within the extracted context
// Now any spans created here will be children of the original trace
context.with(parentContext, () => {
tracer.startActiveSpan("processEvent", (span) => {
span.setAttribute("queue.message_id", message.id);
handleEvent(message.payload);
span.end();
});
});
}أين ترسل التتبعات#
Jaeger — الخيار مفتوح المصدر الكلاسيكي. واجهة مستخدم جيدة، سهل التشغيل محلياً مع Docker. تخزين محدود طويل المدى.
Grafana Tempo — إذا كنت تستخدم بالفعل Grafana وLoki، فـ Tempo هو الخيار الطبيعي للتتبعات. يستخدم تخزين الكائنات (S3/GCS) للاحتفاظ الفعّال من حيث التكلفة طويل المدى.
Grafana Cloud / Datadog / Honeycomb — إذا لم ترد تشغيل البنية التحتية. أكثر تكلفة، عبء تشغيلي أقل.
للتطوير المحلي، Jaeger في Docker مثالي:
# docker-compose.otel.yml
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # Jaeger UI
- "4318:4318" # OTLP HTTP receiver
environment:
- COLLECTOR_OTLP_ENABLED=trueنقاط نهاية فحص الصحة#
فحوصات الصحة هي أبسط أشكال قابلية الملاحظة وأول شيء يجب تنفيذه. تجيب على سؤال واحد: "هل هذه الخدمة قادرة على خدمة الطلبات الآن؟"
ثلاثة أنواع من فحوصات الصحة#
/health — الصحة العامة. هل العملية تعمل وتستجيب؟
/ready — الجاهزية. هل يمكن لهذه الخدمة التعامل مع الحركة؟ (هل اتصلت بقاعدة البيانات؟ هل حمّلت إعداداتها؟ هل دفّأت ذاكرتها المؤقتة؟)
/live — الحيوية. هل العملية حية وغير متوقفة؟ (هل يمكنها الاستجابة لطلب بسيط ضمن مهلة زمنية؟)
التمييز مهم لـ Kubernetes، حيث مسبارات الحيوية تعيد تشغيل الحاويات المتوقفة ومسبارات الجاهزية تزيل الحاويات من موزّع الحمل أثناء بدء التشغيل أو فشل التبعيات.
// src/routes/health.ts
import { Router } from "express";
import { Pool } from "pg";
import Redis from "ioredis";
const router = Router();
interface HealthCheckResult {
status: "ok" | "degraded" | "error";
checks: Record<
string,
{
status: "ok" | "error";
latency?: number;
message?: string;
}
>;
uptime: number;
timestamp: string;
version: string;
}
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) {
// Liveness — just check if the process can respond
router.get("/live", (_req, res) => {
res.status(200).json({ status: "ok" });
});
// Readiness — check all dependencies
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,
},
});
});
// Full health — detailed status for dashboards and debugging
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;
const result: HealthCheckResult = {
status: allError ? "error" : anyError ? "degraded" : "ok",
checks: {
database: {
status: db.ok ? "ok" : "error",
latency: db.latency,
...(!db.ok && { message: "Connection failed" }),
},
redis: {
status: cache.ok ? "ok" : "error",
latency: cache.latency,
...(!cache.ok && { message: "Connection failed" }),
},
},
uptime: process.uptime(),
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION || "unknown",
};
// Return 200 for ok/degraded (service can still handle some traffic)
// Return 503 for error (service should be removed from rotation)
res.status(result.status === "error" ? 503 : 200).json(result);
});
return router;
}إعداد مسبار Kubernetes#
# k8s/deployment.yml
spec:
containers:
- name: api
livenessProbe:
httpGet:
path: /live
port: 3000
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 3 # Restart after 3 consecutive failures (45s)
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 2 # Remove from LB after 2 failures (20s)
startupProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 30 # Give up to 150s for startupخطأ شائع: جعل مسبار الحيوية عدوانياً جداً. إذا كان مسبار الحيوية يفحص قاعدة البيانات، وقاعدة البيانات معطّلة مؤقتاً، سيعيد Kubernetes تشغيل حاويتك. لكن إعادة التشغيل لن تصلح قاعدة البيانات. الآن لديك حلقة انهيار فوق انقطاع قاعدة البيانات. اجعل مسبارات الحيوية بسيطة — يجب أن تكتشف فقط العمليات المتوقفة أو المحجوبة.
تتبع الأخطاء مع Sentry#
السجلات تلتقط الأخطاء التي توقعتها. Sentry يلتقط التي لم تتوقعها.
الفرق مهم. تضيف كتل try/catch حول الكود الذي تعرف أنه قد يفشل. لكن الأخطاء التي تهم أكثر هي تلك في الكود الذي ظننت أنه آمن. رفض وعود غير معالج، أخطاء نوع من استجابات API غير متوقعة، الوصول لقيمة فارغة في سلاسل اختيارية لم تكن اختيارية بما يكفي.
إعداد Sentry لـ Node.js#
// src/lib/sentry.ts
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
export function initSentry() {
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV || "development",
release: process.env.APP_VERSION || "unknown",
// Sample 10% of transactions for performance monitoring
// (100% in development)
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
// Profile 100% of sampled transactions
profilesSampleRate: 1.0,
integrations: [
nodeProfilingIntegration(),
// Filter out noisy errors
Sentry.rewriteFramesIntegration({
root: process.cwd(),
}),
],
// Don't send errors from development
enabled: process.env.NODE_ENV === "production",
// Filter out known non-issues
ignoreErrors: [
// Client disconnects aren't bugs
"ECONNRESET",
"ECONNABORTED",
"EPIPE",
// Bots sending garbage
"SyntaxError: Unexpected token",
],
// Strip PII before sending
beforeSend(event) {
// Remove IP addresses
if (event.request) {
delete event.request.headers?.["x-forwarded-for"];
delete event.request.headers?.["x-real-ip"];
delete event.request.cookies;
}
// Remove sensitive query params
if (event.request?.query_string) {
const params = new URLSearchParams(event.request.query_string);
params.delete("token");
params.delete("api_key");
event.request.query_string = params.toString();
}
return event;
},
});
}معالج أخطاء Express مع Sentry#
// src/middleware/error-handler.ts
import * as Sentry from "@sentry/node";
import { getLogger } from "../lib/async-context";
import type { Request, Response, NextFunction } from "express";
// Sentry request handler must come first
export const sentryRequestHandler = Sentry.Handlers.requestHandler();
// Sentry tracing handler
export const sentryTracingHandler = Sentry.Handlers.tracingHandler();
// Your custom error handler comes last
export function errorHandler(
err: Error,
req: Request,
res: Response,
_next: NextFunction
) {
const log = getLogger();
// Add custom context to the Sentry event
Sentry.withScope((scope) => {
scope.setTag("route", req.route?.path || req.path);
scope.setTag("method", req.method);
if (req.user) {
scope.setUser({
id: req.user.id,
// Don't send email or username to Sentry
});
}
// Add breadcrumbs for debugging
scope.addBreadcrumb({
category: "request",
message: `${req.method} ${req.path}`,
level: "info",
data: {
query: req.query,
statusCode: res.statusCode,
},
});
Sentry.captureException(err);
});
// Log the error with full context
log.error(
{
err,
statusCode: 500,
route: req.route?.path || req.path,
method: req.method,
},
"Unhandled error in request handler"
);
// Send a generic error response
// Never expose error details to the client in production
res.status(500).json({
error: "Internal Server Error",
...(process.env.NODE_ENV !== "production" && {
message: err.message,
stack: err.stack,
}),
});
}خرائط المصدر#
بدون خرائط المصدر، يعرض لك Sentry آثار مكدّس مضغوطة/محوّلة. بلا فائدة. ارفع خرائط المصدر أثناء البناء:
# In your CI/CD pipeline
npx @sentry/cli sourcemaps upload \
--org your-org \
--project your-project \
--release $APP_VERSION \
./distأو اضبطه في أداة التجميع:
// vite.config.ts (or equivalent)
import { sentryVitePlugin } from "@sentry/vite-plugin";
export default defineConfig({
build: {
sourcemap: true, // Required for Sentry
},
plugins: [
sentryVitePlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
}),
],
});تكلفة رفض الوعود غير المعالجة#
منذ Node.js 15، رفض الوعود غير المعالجة يُسقط العملية بشكل افتراضي. هذا جيد — يجبرك على معالجة الأخطاء. لكنك تحتاج شبكة أمان:
// src/server.ts — near the top of your entry point
process.on("unhandledRejection", (reason, promise) => {
logger.fatal({ reason, promise }, "Unhandled promise rejection — crashing");
Sentry.captureException(reason);
// Flush Sentry events before crashing
Sentry.flush(2000).finally(() => {
process.exit(1);
});
});
process.on("uncaughtException", (error) => {
logger.fatal({ err: error }, "Uncaught exception — crashing");
Sentry.captureException(error);
Sentry.flush(2000).finally(() => {
process.exit(1);
});
});الجزء المهم: Sentry.flush() قبل process.exit(). بدونه، قد لا يصل حدث الخطأ إلى Sentry قبل موت العملية.
التنبيه: التنبيهات التي تهم فعلاً#
امتلاك 200 مقياس Prometheus وصفر تنبيهات هو مجرد مراقبة زائفة. امتلاك 50 تنبيهاً يُطلق كل يوم هو إرهاق تنبيهات — ستبدأ بتجاهلها، وعندها ستفوّت التنبيه الذي يهم.
الهدف هو عدد قليل من التنبيهات عالية الإشارة التي تعني "شيء خاطئ فعلاً وإنسان يحتاج للنظر فيه."
إعداد AlertManager في Prometheus#
# alertmanager.yml
global:
resolve_timeout: 5m
slack_api_url: $SLACK_WEBHOOK_URL
route:
receiver: "slack-warnings"
group_by: ["alertname", "service"]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
- match:
severity: critical
receiver: "pagerduty-critical"
repeat_interval: 15m
- match:
severity: warning
receiver: "slack-warnings"
receivers:
- name: "pagerduty-critical"
pagerduty_configs:
- routing_key: $PAGERDUTY_ROUTING_KEY
severity: critical
- name: "slack-warnings"
slack_configs:
- channel: "#alerts"
title: '{{ template "slack.title" . }}'
text: '{{ template "slack.text" . }}'التنبيهات التي توقظني فعلاً#
# prometheus/rules/node-api.yml
groups:
- name: node-api-critical
rules:
# High error rate — something is broken
- alert: HighErrorRate
expr: |
(
sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
/
sum(rate(nodeapp_http_requests_total[5m]))
) > 0.01
for: 5m
labels:
severity: critical
annotations:
summary: "Error rate above 1% for 5 minutes"
description: "{{ $value | humanizePercentage }} of requests are returning 5xx"
# Slow responses — users are suffering
- alert: HighP99Latency
expr: |
histogram_quantile(0.99,
sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
) > 1
for: 5m
labels:
severity: critical
annotations:
summary: "p99 latency above 1 second for 5 minutes"
description: "p99 latency is {{ $value | humanizeDuration }}"
# Memory leak — will OOM soon
- alert: HighHeapUsage
expr: |
(
nodeapp_nodejs_heap_size_used_bytes
/
nodeapp_nodejs_heap_size_total_bytes
) > 0.80
for: 10m
labels:
severity: critical
annotations:
summary: "Heap usage above 80% for 10 minutes"
description: "Heap usage is at {{ $value | humanizePercentage }}"
# Process is down
- alert: ServiceDown
expr: up{job="node-api"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Node.js API is down"
- name: node-api-warnings
rules:
# Event loop is getting slow
- alert: HighEventLoopLag
expr: |
nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"} > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "Event loop lag above 100ms"
description: "p99 event loop lag is {{ $value | humanizeDuration }}"
# Traffic dropped significantly — possible routing issue
- alert: TrafficDrop
expr: |
sum(rate(nodeapp_http_requests_total[5m]))
< (sum(rate(nodeapp_http_requests_total[5m] offset 1h)) * 0.5)
for: 10m
labels:
severity: warning
annotations:
summary: "Traffic dropped by more than 50% compared to 1 hour ago"
# Database queries getting slow
- alert: SlowDatabaseQueries
expr: |
histogram_quantile(0.99,
sum(rate(nodeapp_db_query_duration_seconds_bucket[5m])) by (le, operation)
) > 0.5
for: 5m
labels:
severity: warning
annotations:
summary: "p99 database query time above 500ms"
description: "Slow {{ $labels.operation }} queries: {{ $value | humanizeDuration }}"
# External API is failing
- alert: ExternalAPIFailures
expr: |
(
sum(rate(nodeapp_external_api_duration_seconds_count{status="error"}[5m])) by (service)
/
sum(rate(nodeapp_external_api_duration_seconds_count[5m])) by (service)
) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "External API {{ $labels.service }} failing >10%"لاحظ جملة for في كل تنبيه. بدونها، ارتفاع واحد يُطلق تنبيهاً. for لمدة 5 دقائق يعني أن الشرط يجب أن يكون صحيحاً لمدة 5 دقائق متواصلة. هذا يزيل الضوضاء من النبضات اللحظية.
نظافة التنبيهات#
كل تنبيه يجب أن يجتاز هذا الاختبار:
- هل هو قابل للتنفيذ؟ إذا لم يستطع أحد فعل شيء حياله، لا تنبّه. سجّله، اعرضه على لوحة المعلومات، لكن لا توقظ أحداً.
- هل يتطلب تدخلاً بشرياً؟ إذا كان يشفى ذاتياً (مثل انقطاع شبكة قصير)، جملة
forيجب أن تصفّيه. - هل أُطلق في آخر 30 يوماً؟ إذا لم يكن كذلك، قد يكون مضبوطاً بشكل خاطئ أو العتبة خاطئة. راجعه.
- عندما يُطلق، هل يهتم الناس؟ إذا كان الفريق يتجاهله بانتظام، أزله أو أصلح العتبة.
أراجع تنبيهاتي كل ثلاثة أشهر. كل تنبيه يحصل على واحدة من ثلاث نتائج: أبقِ، اضبط العتبة، أو احذف.
جمع كل شيء معاً: تطبيق Express#
إليك كيف تتلاءم جميع القطع معاً في تطبيق حقيقي:
// src/server.ts
import { initSentry } from "./lib/sentry";
// Initialize Sentry first — before other imports
initSentry();
import express from "express";
import * as Sentry from "@sentry/node";
import { Pool } from "pg";
import Redis from "ioredis";
import { logger } from "./lib/logger";
import { asyncContextMiddleware } from "./middleware/async-context-middleware";
import { metricsMiddleware } from "./middleware/metrics-middleware";
import { requestLogger } from "./middleware/request-logger";
import {
sentryRequestHandler,
sentryTracingHandler,
errorHandler,
} from "./middleware/error-handler";
import { createHealthRoutes } from "./routes/health";
import metricsRouter from "./routes/metrics";
import apiRouter from "./routes/api";
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL);
// --- Middleware Order Matters ---
// 1. Sentry request handler (must be first)
app.use(sentryRequestHandler);
app.use(sentryTracingHandler);
// 2. Async context (creates request-scoped context)
app.use(asyncContextMiddleware);
// 3. Request logging
app.use(requestLogger);
// 4. Metrics collection
app.use(metricsMiddleware);
// 5. Body parsing
app.use(express.json({ limit: "1mb" }));
// --- Routes ---
// Health checks (no auth required)
app.use(createHealthRoutes(pool, redis));
// Metrics (basic auth protected)
app.use(metricsRouter);
// API routes
app.use("/api", apiRouter);
// --- Error Handling ---
// Sentry error handler (must be before custom error handler)
app.use(Sentry.Handlers.errorHandler());
// Custom error handler (must be last)
app.use(errorHandler);
// --- Start ---
const port = parseInt(process.env.PORT || "3000", 10);
app.listen(port, () => {
logger.info(
{
port,
nodeEnv: process.env.NODE_ENV,
version: process.env.APP_VERSION,
},
"Server started"
);
});
// Graceful shutdown
async function shutdown(signal: string) {
logger.info({ signal }, "Shutdown signal received");
// Stop accepting new connections
// Process in-flight requests (Express does this automatically)
// Close database pool
await pool.end().catch((err) => {
logger.error({ err }, "Error closing database pool");
});
// Close Redis connection
await redis.quit().catch((err) => {
logger.error({ err }, "Error closing Redis connection");
});
// Flush Sentry
await Sentry.close(2000);
logger.info("Shutdown complete");
process.exit(0);
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));الحزمة الدنيا القابلة للاستخدام#
كل ما سبق هو الحزمة "الكاملة". لا تحتاج كل هذا في اليوم الأول. إليك كيف تتدرج في قابلية الملاحظة مع نمو مشروعك.
المرحلة 1: مشروع جانبي / مطور منفرد#
تحتاج ثلاثة أشياء:
-
سجلات وحدة تحكم مُهيكلة — استخدم Pino، أخرج JSON إلى stdout. حتى لو كنت تقرأها فقط مع
pm2 logs، سجلات JSON قابلة للبحث والتحليل. -
نقطة نهاية /health — تستغرق 5 دقائق للتنفيذ، تنقذك عند تصحيح "هل هي تعمل أصلاً؟"
-
الطبقة المجانية من Sentry — تلتقط الأخطاء التي لم تتوقعها. الطبقة المجانية تمنحك 5,000 حدث/شهر، وهي كافية لمشروع جانبي.
// This is the minimal setup. Under 50 lines. No excuses.
import pino from "pino";
import express from "express";
import * as Sentry from "@sentry/node";
const logger = pino({ level: "info" });
const app = express();
Sentry.init({ dsn: process.env.SENTRY_DSN });
app.use(Sentry.Handlers.requestHandler());
app.get("/health", (_req, res) => {
res.json({ status: "ok", uptime: process.uptime() });
});
app.use("/api", apiRoutes);
app.use(Sentry.Handlers.errorHandler());
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
logger.error({ err }, "Unhandled error");
res.status(500).json({ error: "Internal Server Error" });
});
app.listen(3000, () => logger.info("Server started on port 3000"));المرحلة 2: مشروع متنامٍ / فريق صغير#
أضف:
-
مقاييس Prometheus + Grafana — عندما "يبدو بطيئاً" لا يكفي وتحتاج بيانات. ابدأ بمعدل الطلبات، ومعدل الأخطاء، وزمن الاستجابة p99.
-
تجميع السجلات — عندما
sshإلى الخادم وgrepعبر الملفات يتوقف عن التوسع. Loki + Promtail إذا كنت تستخدم بالفعل Grafana. -
تنبيهات أساسية — معدل الأخطاء > 1%، p99 > ثانية واحدة، الخدمة معطّلة. ثلاثة تنبيهات. هذا كل شيء.
المرحلة 3: خدمة إنتاجية / خدمات متعددة#
أضف:
-
التتبع الموزّع مع OpenTelemetry — عندما "الـ API بطيء" يصبح "أي من الخدمات الخمس التي يستدعيها بطيء؟" الأدوات التلقائية لـ OTel تعطيك 80% من القيمة بدون أي تغييرات في الكود.
-
لوحة المعلومات كشفرة — تحكّم في إصدار لوحات معلومات Grafana. ستشكر نفسك عندما تحتاج لإعادة إنشائها.
-
تنبيهات مُهيكلة — AlertManager مع توجيه مناسب، وتصعيد، وقواعد كتم.
-
مقاييس الأعمال — طلبات/ثانية، معدل التحويل، عمق قائمة الانتظار. المقاييس التي يهتم بها فريق المنتج.
ما تتخطاه#
- موردو APM بتسعير لكل مضيف — في النطاق الواسع، التكلفة باهظة. مفتوح المصدر (Prometheus + Grafana + Tempo + Loki) يعطيك 95% من الوظائف.
- مستويات سجل أقل من INFO في الإنتاج — ستولّد تيرابايت من سجلات DEBUG وتدفع للتخزين. استخدم DEBUG فقط عند التحقيق النشط في مشاكل، ثم أغلقه.
- مقاييس مخصصة لكل شيء — ابدأ بطريقة RED (المعدل، الأخطاء، المدة) لكل خدمة. أضف مقاييس مخصصة فقط عندما يكون لديك سؤال محدد للإجابة عليه.
- أخذ عينات تتبع معقد — ابدأ بمعدل عينة بسيط (10% في الإنتاج). أخذ العينات التكيفي هو تحسين سابق لأوانه لمعظم الفرق.
أفكار ختامية#
قابلية الملاحظة ليست منتجاً تشتريه أو أداة تثبّتها. إنها ممارسة. إنها الفرق بين تشغيل خدمتك والأمل في أن خدمتك تشغّل نفسها.
الحزمة التي وصفتها هنا — Pino للسجلات، Prometheus للمقاييس، OpenTelemetry للتتبعات، Sentry للأخطاء، Grafana للعرض البصري، AlertManager للتنبيهات — ليست أبسط إعداد ممكن. لكن كل قطعة تكسب مكانها بالإجابة على سؤال لا تستطيع القطع الأخرى الإجابة عليه.
ابدأ بسجلات مُهيكلة ونقطة نهاية صحة. أضف مقاييس عندما تحتاج لمعرفة "كم هو سيئ." أضف تتبعات عندما تحتاج لمعرفة "أين يذهب الوقت." كل طبقة تُبنى على السابقة، ولا تتطلب أي منها إعادة كتابة تطبيقك.
أفضل وقت لإضافة قابلية الملاحظة كان قبل آخر حادثة إنتاجية. ثاني أفضل وقت هو الآن.