Node.js'de Gözlemlenebilirlik: Karmaşıklık Olmadan Loglar, Metrikler ve İzler
Pino ile yapılandırılmış loglama, Prometheus ile metrikler, OpenTelemetry ile dağıtık izleme. Node.js üretim uygulamaları için kullandığım gözlemlenebilirlik yığını, sıfırdan tam görünürlüğe.
Eskiden gözlemlenebilirliğin "birkaç console.log ekle ve bir şey bozulduğunda kontrol et" anlamına geldiğini düşünürdüm. Bu, işe yaramayı bırakana kadar işe yaradı. Kırılma noktası, API'mizin 200 döndürdüğü ama verilerin bayat olduğu bir üretim olayıydı. Loglarda hata yok. İstisna yok. Sadece sessizce yanlış yanıtlar; çünkü bir alt akış önbelleği bayatlamıştı ve kimse dört saat boyunca fark etmemişti.
O zaman izleme ile gözlemlenebilirlik arasındaki farkı öğrendim. İzleme size bir şeylerin yanlış olduğunu söyler. Gözlemlenebilirlik size neden yanlış olduğunu söyler. Ve bu ikisi arasındaki boşluk, üretim olaylarının yaşandığı yerdir.
Bu, alternatiflerin çoğunu denedikten sonra Node.js servisleri için benimsediğim gözlemlenebilirlik yığınıdır. Dünyanın en sofistike kurulumu değildir, ama sorunları kullanıcılar fark etmeden yakalar ve bir şey kaçtığında, saatler yerine dakikalar içinde teşhis koyabiliyorum.
Üç Sütun ve Hepsine Neden İhtiyacınız Var#
Herkes "gözlemlenebilirliğin üç sütunu"ndan bahseder — loglar, metrikler ve izler. Kimsenin size söylemediği şey, her sütunun temelden farklı bir soruyu yanıtladığı ve hiçbir sütunun tek başına her soruyu yanıtlayamadığı için üçüne de ihtiyacınız olduğudur.
Loglar şu soruyu yanıtlar: Ne oldu?
Bir log satırı "14:23:07'de, kullanıcı 4821 /api/orders'a istek yaptı ve veritabanı bağlantısı zaman aşımına uğradığı için 500 aldı" der. Bu bir anlatıdır. Size belirli bir olayın hikayesini anlatır.
Metrikler şu soruyu yanıtlar: Ne kadar oluyor?
Bir metrik "son 5 dakikada, p99 yanıt süresi 2.3 saniyeydi ve hata oranı %4.7 idi" der. Bu toplu veridir. Size herhangi bir bireysel istek hakkında değil, bir bütün olarak sistemin sağlığı hakkında bilgi verir.
İzler şu soruyu yanıtlar: Zaman nereye gitti?
Bir iz "bu istek Express ara katmanında 12ms, gövde ayrıştırmada 3ms, PostgreSQL beklemede 847ms ve yanıt serileştirmede 2ms harcadı" der. Bu bir şelaledir. Size darboğazın tam olarak nerede olduğunu, servis sınırları boyunca gösterir.
İşte pratik sonuç: gece saat 3'te çağrı cihazınız çaldığında, sıra neredeyse her zaman aynıdır.
- Metrikler size bir şeylerin yanlış olduğunu söyler (hata oranı artışı, gecikme artışı)
- Loglar size neler olduğunu söyler (belirli hata mesajları, etkilenen uç noktalar)
- İzler size nedenini söyler (hangi alt akış servisi veya veritabanı sorgusu darboğaz)
Yalnızca loglarınız varsa, neyin bozulduğunu bilirsiniz ama ne kadar kötü olduğunu bilemezsiniz. Yalnızca metrikleriniz varsa, ne kadar kötü olduğunu bilirsiniz ama neyin neden olduğunu bilemezsiniz. Yalnızca izleriniz varsa, güzel şelaleleriniz olur ama ne zaman bakmanız gerektiğini bilemezsiniz.
Her birini oluşturalım.
Pino ile Yapılandırılmış Loglama#
console.log Neden Yeterli Değil#
Biliyorum. Üretimde console.log kullanıyorsunuz ve "sorun yok." Size neden sorun olduğunu göstereyim.
// Yazdığınız şey
console.log("User login failed", email, error.message);
// Log dosyanızda ortaya çıkan şey
// User login failed john@example.com ECONNREFUSED
// Şimdi şunu deneyin:
// 1. Son bir saatteki tüm giriş başarısızlıklarını arama
// 2. Kullanıcı başına başarısızlıkları sayma
// 3. Sadece ECONNREFUSED hatalarını filtreleme
// 4. Bunu tetikleyen istekle ilişkilendirme
// İyi şanslar. Yapılandırılmamış bir string. Metin içinde grep yapıyorsunuz.Yapılandırılmış loglama, her log girdisinin tutarlı alanlara sahip bir JSON nesnesi olması demektir. Makine düşmanı, insan tarafından okunabilir bir string yerine, (doğru araçlarla) insan tarafından da okunabilir, makine tarafından okunabilir bir nesne elde edersiniz.
// Yapılandırılmış loglama neye benzer
{
"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
}Şimdi bunu sorgulayabilirsiniz. level >= 50 AND msg = "User login failed" AND time > now() - 1h tam olarak ihtiyacınız olanı verir.
Pino vs Winston#
Her ikisini de kapsamlı şekilde kullandım. İşte kısa versiyonu:
Winston daha popüler, daha esnek, daha fazla taşıyıcıya sahip ve önemli ölçüde daha yavaş. Ayrıca kötü kalıpları teşvik eder — "format" sistemi, geliştirmede güzel görünen ama üretimde ayrıştırılamayan yapılandırılmamış, güzel biçimlendirilmiş loglar oluşturmayı çok kolaylaştırır.
Pino daha hızlı (kıyaslamalarda 5-10 kat), JSON çıktısı konusunda fikirli ve Unix felsefesini takip eder: tek bir şeyi iyi yap (stdout'a JSON yaz) ve gerisini başka araçların halletmesine izin ver (taşıma, biçimlendirme, toplama).
Ben Pino kullanıyorum. Performans farkı, saniyede binlerce istek logladığınızda önemlidir ve fikirli yaklaşım, ekipteki her geliştiricinin tutarlı loglar üretmesini sağlar.
Temel Pino Kurulumu#
// 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"),
// Üretimde sadece stdout'a JSON. PM2/container runtime gerisini halleder.
// Geliştirmede, insan tarafından okunabilir çıktı için pino-pretty kullanın.
...(isProduction
? {}
: {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss",
ignore: "pid,hostname",
},
},
}),
// Her log satırındaki standart alanlar
base: {
service: process.env.SERVICE_NAME || "api",
version: process.env.APP_VERSION || "unknown",
},
// Error nesnelerini düzgün serileştir
serializers: {
err: pino.stdSerializers.err,
error: pino.stdSerializers.err,
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
},
// Hassas alanları gizle
redact: {
paths: [
"req.headers.authorization",
"req.headers.cookie",
"password",
"creditCard",
"ssn",
],
censor: "[REDACTED]",
},
});redact seçeneği kritiktir. Bu olmadan, er ya da geç bir parola veya API anahtarını loglarsınız. Bu bir "eğer" meselesi değil, "ne zaman" meselesidir. Bir geliştirici logger.info({ body: req.body }, "incoming request") ekleyecek ve aniden kredi kartı numaralarını logluyorsunuzdur. Gizleme, güvenlik ağınızdır.
Log Seviyeleri: Düzgün Kullanın#
// FATAL (60) - Süreç çökmek üzere. Birini uyandırın.
logger.fatal({ err }, "Unrecoverable database connection failure");
// ERROR (50) - Olmaması gereken bir şey başarısız oldu. Yakında araştırın.
logger.error({ err, userId, orderId }, "Payment processing failed");
// WARN (40) - Beklenmedik ama yönetilen bir durum. Göz kulak olun.
logger.warn({ retryCount: 3, service: "email" }, "Retry limit approaching");
// INFO (30) - Kaydedilmeye değer normal işlemler. "Ne oldu" logu.
logger.info({ userId, action: "login" }, "User authenticated");
// DEBUG (20) - Hata ayıklama için ayrıntılı bilgi. Asla üretimde değil.
logger.debug({ query, params }, "Database query executing");
// TRACE (10) - Son derece ayrıntılı. Sadece çaresiz kaldığınızda.
logger.trace({ headers: req.headers }, "Incoming request headers");Kural: INFO ve DEBUG arasında kararsız kalıyorsanız, DEBUG'tır. WARN ve ERROR arasında kararsız kalıyorsanız, kendinize sorun: "Gece 3'te bu konuda uyarılmak ister miyim?" Evetse, ERROR. Hayırsa, WARN.
Alt Loggerlar ve İstek Bağlamı#
Pino'nun gerçekten parladığı yer burasıdır. Bir alt logger, ebeveynin tüm yapılandırmasını miras alır ama ekstra bağlam alanları ekler.
// Bu alt loggerdan gelen her log userId ve sessionId içerecek
const userLogger = logger.child({ userId: "usr_4821", sessionId: "ses_xyz" });
userLogger.info("User viewed dashboard");
// Çıktı userId ve sessionId'yi otomatik olarak içerir
userLogger.info({ page: "/settings" }, "User navigated");
// Çıktı userId, sessionId VE page içerirHTTP sunucuları için, her isteğe bir alt logger istiyorsunuz; böylece o isteğin yaşam döngüsündeki her log satırı istek ID'sini içerir:
// 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();
// İsteğe bir alt logger ekle
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,
});
// Korelasyon için yanıtta istek ID header'ını ayarla
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();
}Otomatik Bağlam Yayılımı için AsyncLocalStorage#
Alt logger yaklaşımı işe yarar, ama her fonksiyon çağrısından req.log geçirmenizi gerektirir. Bu yorucu olur. AsyncLocalStorage bunu çözer — açık geçirme olmadan asenkron yürütme akışını takip eden bir bağlam deposu sağlar.
// 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>();
// Çağrı yığınının herhangi bir yerinden bağlamsal logger'ı al
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();
});
}Artık çağrı yığınının herhangi bir yerindeki herhangi bir fonksiyon, istek kapsamlı logger'ı alabilir:
// src/services/order-service.ts
import { getLogger } from "../lib/async-context";
export async function processOrder(orderId: string) {
const log = getLogger(); // Otomatik olarak requestId ekli!
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'ı parametre olarak geçirmeye gerek yok. Sadece çalışıyor.Log Toplama: Loglar Nereye Gider?#
Geliştirmede loglar stdout'a gider ve pino-pretty onları okunabilir yapar. Üretimde durum daha nüanslıdır.
PM2 Yolu#
PM2 ile bir VPS'te çalıştırıyorsanız (VPS kurulum yazımda anlattığım gibi), PM2 stdout'u otomatik olarak yakalar:
# Logları gerçek zamanlı görüntüle
pm2 logs api --lines 100
# Loglar ~/.pm2/logs/ konumunda saklanır
# api-out.log — stdout (JSON loglarınız)
# api-error.log — stderr (yakalanmamış istisnalar, yığın izleri)PM2'nin yerleşik log döndürme özelliği disk alanı sorunlarını önler:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 14
pm2 set pm2-logrotate:compress trueLogları Loki veya Elasticsearch'e Gönderme#
Tek bir sunucunun ötesinde herhangi bir şey için merkezi log toplama gerekir. İki ana seçenek:
Grafana Loki — "Loglar için Prometheus." Hafif, sadece etiketleri indeksler (tam metin değil), Grafana ile harika çalışır. Çoğu ekip için benim önerim.
Elasticsearch — Loglar üzerinde tam metin araması. Daha güçlü, daha fazla kaynak tüketen, daha fazla operasyonel yük. Gerçekten milyonlarca log satırında tam metin aramasına ihtiyacınız varsa bunu kullanın.
Loki için en basit kurulum logları göndermek için Promtail kullanır:
# /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 varsayılan olarak Satır Ayırımlı JSON (NDJSON) çıktısı verir — satır başına bir JSON nesnesi, \n ile ayrılmış. Bu önemlidir çünkü:
- Her log toplama aracı bunu anlar
- Akış halinde işlenebilir (dosyanın tamamını tamponlamadan satır satır logları işleyebilirsiniz)
- Standart Unix araçları bunun üzerinde çalışır:
cat api-out.log | jq '.msg' | sort | uniq -c | sort -rn
Asla Pino'yu üretimde güzel biçimlendirilmiş, çok satırlı JSON çıktısı verecek şekilde yapılandırmayın. Pipeline'daki her aracı bozarsınız.
// ÜRETİMDE YANLIŞ — çok satırlı JSON, satır tabanlı işlemeyi bozar
{
"level": 30,
"time": 1709312587000,
"msg": "Request completed"
}
// ÜRETİMDE DOĞRU — NDJSON, satır başına bir nesne
{"level":30,"time":1709312587000,"msg":"Request completed"}Prometheus ile Metrikler#
Loglar ne olduğunu söyler. Metrikler sistemin nasıl performans gösterdiğini söyler. Fark, banka ekstrenizde her işlemi okumakla hesap bakiyenize bakmak arasındaki fark gibidir.
Dört Metrik Türü#
Prometheus'un dört metrik türü vardır. Hangisinin ne zaman kullanılacağını anlamak sizi en yaygın hatalardan kurtaracaktır.
Counter — Sadece yukarı giden bir değer. İstek sayısı, hata sayısı, işlenen bayt. Yeniden başlatmada sıfırlanır.
// "Kaç istek sunduk?"
const httpRequestsTotal = new Counter({
name: "http_requests_total",
help: "Total number of HTTP requests",
labelNames: ["method", "route", "status_code"],
});Gauge — Yukarı veya aşağı gidebilen bir değer. Mevcut bağlantılar, kuyruk boyutu, sıcaklık, heap kullanımı.
// "Şu anda kaç bağlantı aktif?"
const activeConnections = new Gauge({
name: "active_connections",
help: "Number of currently active connections",
});Histogram — Değerleri gözlemler ve yapılandırılabilir kovalara yerleştirir. İstek süresi, yanıt boyutu. Yüzdelikleri (p50, p95, p99) böyle elde edersiniz.
// "İstekler ne kadar sürüyor?" 10ms, 50ms, 100ms vb. kovalarla
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'a benzer ama kantilleri istemci tarafında hesaplar. Belirli bir nedeniniz yoksa bunun yerine Histogram kullanın. Summary'ler örnekler arasında toplanamaz.
Tam prom-client Kurulumu#
// src/lib/metrics.ts
import {
Registry,
Counter,
Histogram,
Gauge,
collectDefaultMetrics,
} from "prom-client";
// Global olanı kirletmemek için özel bir kayıt defteri oluştur
export const metricsRegistry = new Registry();
// Varsayılan Node.js metriklerini topla:
// - 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_",
// Her 10 saniyede topla
gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5],
});
// --- HTTP Metrikleri ---
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],
});
// --- İş Metrikleri ---
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],
});Metrik Ara Katmanı#
// src/middleware/metrics-middleware.ts
import { httpRequestsTotal, httpRequestDuration } from "../lib/metrics";
import type { Request, Response, NextFunction } from "express";
// Kardinalite patlamasını önlemek için rotaları normalleştir
// /api/users/123 → /api/users/:id
// Bu olmadan Prometheus her kullanıcı ID'si için yeni bir zaman serisi oluşturacak
function normalizeRoute(req: Request): string {
const route = req.route?.path || req.path;
// Yaygın dinamik segmentleri değiştir
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
) {
// Metrik uç noktasının kendisi için metrikleri izleme
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 Uç Noktası#
// src/routes/metrics.ts
import { Router } from "express";
import { metricsRegistry } from "../lib/metrics";
const router = Router();
router.get("/metrics", async (req, res) => {
// Temel auth koruması — metrikleri herkese açık gösterme
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;Özel İş Metrikleri Gerçek Güçtür#
Varsayılan Node.js metrikleri (heap boyutu, olay döngüsü gecikmesi, GC süresi) temel gereksinimlerdir. Size çalışma zamanı sağlığı hakkında bilgi verirler. Ama iş metrikleri size uygulama sağlığı hakkında bilgi verir.
// Sipariş servisinizde
import { ordersProcessed, externalApiDuration } from "../lib/metrics";
export async function processOrder(order: Order) {
try {
// Ödeme sağlayıcı çağrısını zamanlayın
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"} değerindeki bir artış, hiçbir CPU metriğinin asla söyleyemeyeceği bir şeyi size söyler.
Etiket Kardinalitesi: Sessiz Katil#
Bir uyarı. Her benzersiz etiket değeri kombinasyonu yeni bir zaman serisi oluşturur. HTTP istek sayacınıza bir userId etiketi eklerseniz ve 100.000 kullanıcınız varsa, az önce 100.000'den fazla zaman serisi oluşturdunuz. Prometheus durma noktasına gelecektir.
Etiketler için kurallar:
- Sadece düşük kardinalite: HTTP metodu (7 değer), durum kodu (5 kategori), rota (onlarca, binlerce değil)
- Asla kullanıcı ID'lerini, istek ID'lerini, IP adreslerini veya zaman damgalarını etiket değerleri olarak kullanmayın
- Emin değilseniz, etiketi eklemeyin. Daha sonra her zaman ekleyebilirsiniz, ama kaldırmak panoları ve uyarıları değiştirmeyi gerektirir
Grafana Panoları#
Prometheus verileri saklar. Grafana onları görselleştirir. İşte her Node.js servis panosuna koyduğum paneller.
Temel Pano#
1. İstek Oranı (istek/saniye)
rate(nodeapp_http_requests_total[5m])Trafik kalıbını gösterir. Ani artışları veya düşüşleri tespit etmek için kullanışlıdır.
2. Hata Oranı (%)
100 * (
sum(rate(nodeapp_http_requests_total{status_code=~"5.."}[5m]))
/
sum(rate(nodeapp_http_requests_total[5m]))
)En önemli tek sayı. Bu %1'in üzerine çıkarsa, bir şeyler yanlıştır.
3. p50 / p95 / p99 Gecikme
histogram_quantile(0.99,
sum(rate(nodeapp_http_request_duration_seconds_bucket[5m])) by (le)
)p50 size tipik deneyimi söyler. p99 size en kötü deneyimi söyler. p99, p50'nin 10 katıysa, kuyruk gecikme sorununuz vardır.
4. Olay Döngüsü Gecikmesi
nodeapp_nodejs_eventloop_lag_seconds{quantile="0.99"}Bu 100ms'nin üzerine çıkarsa, olay döngünüz engelleniyor demektir. Muhtemelen asenkron bir yolda senkron bir işlem.
5. Heap Kullanımı
nodeapp_nodejs_heap_size_used_bytes / nodeapp_nodejs_heap_size_total_bytes * 100Sürekli yükselen bir trendi izleyin — bu bir bellek sızıntısıdır. GC sırasındaki artışlar normaldir.
6. Aktif Handle'lar
nodeapp_nodejs_active_handles_totalAçık dosya tanımlayıcıları, soketler, zamanlayıcılar. Sürekli artan bir sayı, handle sızdırıyorsunuz demektir — muhtemelen veritabanı bağlantılarını veya HTTP yanıtlarını kapatmıyorsunuz.
Kod Olarak Grafana Panosu#
Panolarınızı Grafana'nın provizyon özelliğini kullanarak sürüm kontrolü altına alabilirsiniz:
# /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: truePano JSON'unuzu Grafana'dan dışa aktarın, deponuza commit edin ve panonuz Grafana yeniden kurulumlarından kurtulur. Bu, üretim için isteğe bağlı değildir — altyapı olarak kod ilkesiyle aynıdır.
OpenTelemetry ile Dağıtık İzleme#
İzleme, çoğu ekibin en son benimsediği ve ilk benimsemeyi dilediği sütundur. Birbirleriyle konuşan birden fazla servisiniz olduğunda (sadece "API sunucusu + veritabanı + Redis + harici API" olsa bile), izleme size bir isteğin yolculuğunun tam resmini gösterir.
İz Nedir?#
Bir iz, span'lardan oluşan bir ağaçtır. Her span bir iş birimini temsil eder — bir HTTP isteği, bir veritabanı sorgusu, bir fonksiyon çağrısı. Span'ların başlangıç zamanı, bitiş zamanı, durumu ve nitelikleri vardır. Servis sınırları boyunca yayılan bir iz ID'si ile birbirine bağlıdırlar.
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)
Tek bakışta anlarsınız: 250ms'lik istek, 180ms'ini Stripe bekleyerek geçirmiş. Optimizasyon yapmanız gereken yer orası.
OpenTelemetry Kurulumu#
OpenTelemetry (OTel) standarttır. Jaeger istemcileri, Zipkin istemcileri ve satıcıya özgü SDK'ların parçalı manzarasını tek, satıcıdan bağımsız bir API ile değiştirdi.
// src/instrumentation.ts
// Bu dosya diğer tüm import'lardan ÖNCE yüklenmelidir.
// Node.js'de --require veya --import flag'ini kullanın.
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",
}),
// İzleri toplayıcınıza gönderin (Jaeger, Tempo, vb.)
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/traces",
}),
// İsteğe bağlı olarak metrikleri de OTel üzerinden gönderin
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318/v1/metrics",
}),
exportIntervalMillis: 15000,
}),
// Otomatik enstrümantasyon: otomatik olarak
// HTTP istekleri, Express rotaları, PostgreSQL sorguları, Redis komutları,
// DNS aramaları ve daha fazlası için span'lar oluşturur
instrumentations: [
getNodeAutoInstrumentations({
// Gürültülü enstrümantasyonları devre dışı bırak
"@opentelemetry/instrumentation-fs": { enabled: false },
"@opentelemetry/instrumentation-dns": { enabled: false },
// Belirli olanları yapılandır
"@opentelemetry/instrumentation-http": {
ignoreIncomingPaths: ["/health", "/ready", "/metrics"],
},
"@opentelemetry/instrumentation-express": {
ignoreLayersType: ["middleware"],
},
}),
],
});
sdk.start();
// Zarif kapanış
process.on("SIGTERM", () => {
sdk.shutdown().then(
() => console.log("OTel SDK shut down successfully"),
(err) => console.error("Error shutting down OTel SDK", err)
);
});Uygulamanızı şöyle başlatın:
node --import ./src/instrumentation.ts ./src/server.tsBu kadar. Uygulama kodunuzda sıfır değişiklikle, artık her HTTP isteği, her veritabanı sorgusu, her Redis komutu için izleriniz var.
Manuel Span Oluşturma#
Otomatik enstrümantasyon altyapı çağrılarını kapsar, ama bazen iş mantığını izlemek istersiniz:
// 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);
// Bu span, bu fonksiyonların içindeki otomatik enstrümante edilmiş
// DB sorgularının veya HTTP çağrılarının ebeveyni olur
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();
}
});
}İz Bağlamı Yayılımı#
Dağıtık izlemenin büyüsü, iz ID'sinin servisler arası isteği takip etmesidir. Servis A, Servis B'yi çağırdığında, iz bağlamı otomatik olarak HTTP başlıklarına enjekte edilir (W3C Trace Context standardına göre traceparent başlığı).
Otomatik enstrümantasyon bunu giden HTTP çağrıları için halleder. Ama bir mesaj kuyruğu kullanıyorsanız, manuel olarak yaymanız gerekir:
import { context, propagation } from "@opentelemetry/api";
// Mesaj yayınlarken
function publishEvent(queue: string, payload: object) {
const carrier: Record<string, string> = {};
// Mevcut iz bağlamını taşıyıcıya enjekte et
propagation.inject(context.active(), carrier);
// Hem yükü hem de iz bağlamını gönder
messageQueue.publish(queue, {
payload,
traceContext: carrier,
});
}
// Mesaj tüketirken
function consumeEvent(message: QueueMessage) {
// İz bağlamını mesajdan çıkar
const parentContext = propagation.extract(
context.active(),
message.traceContext
);
// İşleyiciyi çıkarılan bağlam içinde çalıştır
// Artık burada oluşturulan tüm span'lar orijinal izin çocukları olacak
context.with(parentContext, () => {
tracer.startActiveSpan("processEvent", (span) => {
span.setAttribute("queue.message_id", message.id);
handleEvent(message.payload);
span.end();
});
});
}İzleri Nereye Gönderelim#
Jaeger — Klasik açık kaynak seçenek. İyi kullanıcı arayüzü, Docker ile yerel çalıştırması kolay. Sınırlı uzun vadeli depolama.
Grafana Tempo — Zaten Grafana ve Loki kullanıyorsanız, Tempo izler için doğal seçimdir. Maliyet etkin uzun vadeli saklama için nesne depolaması (S3/GCS) kullanır.
Grafana Cloud / Datadog / Honeycomb — Altyapı çalıştırmak istemiyorsanız. Daha pahalı, daha az operasyonel yük.
Yerel geliştirme için Docker'da Jaeger mükemmeldir:
# docker-compose.otel.yml
services:
jaeger:
image: jaegertracing/all-in-one:latest
ports:
- "16686:16686" # Jaeger UI
- "4318:4318" # OTLP HTTP alıcısı
environment:
- COLLECTOR_OTLP_ENABLED=trueSağlık Kontrol Uç Noktaları#
Sağlık kontrolleri, gözlemlenebilirliğin en basit biçimidir ve uygulamanız gereken ilk şeydir. Tek bir soruyu yanıtlarlar: "Bu servis şu anda istekleri sunabilecek durumda mı?"
Üç Tür Sağlık Kontrolü#
/health — Genel sağlık. Süreç çalışıyor ve yanıt veriyor mu?
/ready — Hazırlık. Bu servis trafiği yönetebilir mi? (Veritabanına bağlandı mı? Yapılandırmasını yükledi mi? Önbelleğini ısıttı mı?)
/live — Canlılık. Süreç canlı ve kilitlenmemiş mi? (Bir zaman aşımı içinde basit bir isteğe yanıt verebilir mi?)
Bu ayrım Kubernetes için önemlidir; canlılık prob'ları takılmış konteynerleri yeniden başlatır ve hazırlık prob'ları başlangıç sırasında veya bağımlılık hatalarında konteynerleri yük dengeleyiciden çıkarır.
// 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) {
// Canlılık — sadece sürecin yanıt verip vermediğini kontrol et
router.get("/live", (_req, res) => {
res.status(200).json({ status: "ok" });
});
// Hazırlık — tüm bağımlılıkları kontrol et
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,
},
});
});
// Tam sağlık — panolar ve hata ayıklama için ayrıntılı durum
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",
};
// ok/degraded için 200 döndür (servis hala bazı trafiği karşılayabilir)
// error için 503 döndür (servis rotasyondan çıkarılmalı)
res.status(result.status === "error" ? 503 : 200).json(result);
});
return router;
}Kubernetes Prob Yapılandırması#
# k8s/deployment.yml
spec:
containers:
- name: api
livenessProbe:
httpGet:
path: /live
port: 3000
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 3 # 3 ardışık başarısızlıktan sonra yeniden başlat (45s)
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 2 # 2 başarısızlıktan sonra LB'den çıkar (20s)
startupProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 30 # Başlangıç için 150s'ye kadar süre verYaygın bir hata: canlılık prob'unu çok agresif yapmak. Canlılık prob'unuz veritabanını kontrol ediyorsa ve veritabanı geçici olarak çökmüşse, Kubernetes konteynerinizi yeniden başlatır. Ama yeniden başlatma veritabanını düzeltmez. Artık veritabanı kesintisinin üzerine bir çökme döngüsünüz var. Canlılık prob'larını basit tutun — sadece kilitlenmiş veya takılmış süreçleri tespit etmeleri gerekir.
Sentry ile Hata Takibi#
Loglar beklediğiniz hataları yakalar. Sentry, beklemediklerinizi yakalar.
Fark önemlidir. Başarısız olabileceğini bildiğiniz kodun etrafına try/catch blokları eklersiniz. Ama en önemli hatalar, güvenli olduğunu düşündüğünüz koddaki hatalardır. İşlenmeyen promise red'leri, beklenmeyen API yanıtlarından tür hataları, yeterince isteğe bağlı olmayan isteğe bağlı zincirlerde null işaretçi erişimi.
Node.js için Sentry Kurulumu#
// 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",
// Performans izleme için işlemlerin %10'unu örnekle
// (geliştirmede %100)
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
// Örneklenen işlemlerin %100'ünü profille
profilesSampleRate: 1.0,
integrations: [
nodeProfilingIntegration(),
// Gürültülü hataları filtrele
Sentry.rewriteFramesIntegration({
root: process.cwd(),
}),
],
// Geliştirmeden hata gönderme
enabled: process.env.NODE_ENV === "production",
// Bilinen sorun olmayanları filtrele
ignoreErrors: [
// İstemci bağlantı kesintileri hata değil
"ECONNRESET",
"ECONNABORTED",
"EPIPE",
// Botlar çöp gönderiyor
"SyntaxError: Unexpected token",
],
// Göndermeden önce kişisel verileri temizle
beforeSend(event) {
// IP adreslerini kaldır
if (event.request) {
delete event.request.headers?.["x-forwarded-for"];
delete event.request.headers?.["x-real-ip"];
delete event.request.cookies;
}
// Hassas sorgu parametrelerini kaldır
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;
},
});
}Sentry ile Express Hata İşleyici#
// 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 istek işleyicisi ilk gelmelidir
export const sentryRequestHandler = Sentry.Handlers.requestHandler();
// Sentry izleme işleyicisi
export const sentryTracingHandler = Sentry.Handlers.tracingHandler();
// Özel hata işleyiciniz en son gelir
export function errorHandler(
err: Error,
req: Request,
res: Response,
_next: NextFunction
) {
const log = getLogger();
// Sentry olayına özel bağlam ekle
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,
// E-posta veya kullanıcı adını Sentry'ye gönderme
});
}
// Hata ayıklama için breadcrumb'lar ekle
scope.addBreadcrumb({
category: "request",
message: `${req.method} ${req.path}`,
level: "info",
data: {
query: req.query,
statusCode: res.statusCode,
},
});
Sentry.captureException(err);
});
// Tam bağlamla hatayı logla
log.error(
{
err,
statusCode: 500,
route: req.route?.path || req.path,
method: req.method,
},
"Unhandled error in request handler"
);
// Genel bir hata yanıtı gönder
// Üretimde asla hata ayrıntılarını istemciye açıklama
res.status(500).json({
error: "Internal Server Error",
...(process.env.NODE_ENV !== "production" && {
message: err.message,
stack: err.stack,
}),
});
}Kaynak Haritaları#
Kaynak haritaları olmadan Sentry size küçültülmüş/dönüştürülmüş yığın izleri gösterir. İşe yaramaz. Derleme sırasında kaynak haritalarını yükleyin:
# CI/CD pipeline'ınızda
npx @sentry/cli sourcemaps upload \
--org your-org \
--project your-project \
--release $APP_VERSION \
./distVeya paketleyicinizde yapılandırın:
// vite.config.ts (veya eşdeğeri)
import { sentryVitePlugin } from "@sentry/vite-plugin";
export default defineConfig({
build: {
sourcemap: true, // Sentry için gerekli
},
plugins: [
sentryVitePlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
}),
],
});İşlenmeyen Promise Red'lerinin Maliyeti#
Node.js 15'ten beri, işlenmeyen promise red'leri varsayılan olarak süreci çökertir. Bu iyidir — sizi hataları ele almaya zorlar. Ama bir güvenlik ağına ihtiyacınız var:
// src/server.ts — giriş noktanızın başına yakın
process.on("unhandledRejection", (reason, promise) => {
logger.fatal({ reason, promise }, "Unhandled promise rejection — crashing");
Sentry.captureException(reason);
// Çökmeden önce Sentry olaylarını temizle
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);
});
});Önemli kısım: process.exit() öncesi Sentry.flush(). Bu olmadan, hata olayı süreç ölmeden önce Sentry'ye ulaşmayabilir.
Uyarı: Gerçekten Önemli Olan Uyarılar#
200 Prometheus metriğiniz ve sıfır uyarınız olması sadece gösteriş izlemesidir. Her gün tetiklenen 50 uyarınız olması uyarı yorgunluğudur — onları görmezden gelmeye başlarsınız ve önemli olanı kaçırırsınız.
Amaç, "bir şeyler gerçekten yanlış ve bir insanın bakması gerekiyor" anlamına gelen az sayıda yüksek sinyalli uyarıdır.
Prometheus AlertManager Yapılandırması#
# 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" . }}'Gerçekten Beni Uyandıran Uyarılar#
# prometheus/rules/node-api.yml
groups:
- name: node-api-critical
rules:
# Yüksek hata oranı — bir şey bozuk
- 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"
# Yavaş yanıtlar — kullanıcılar acı çekiyor
- 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 }}"
# Bellek sızıntısı — yakında OOM olacak
- 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 }}"
# Süreç çökmüş
- 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:
# Olay döngüsü yavaşlıyor
- 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 }}"
# Trafik önemli ölçüde düştü — olası yönlendirme sorunu
- 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"
# Veritabanı sorguları yavaşlıyor
- 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 }}"
# Harici API başarısız oluyor
- 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%"Her uyarıdaki for maddesine dikkat edin. Bu olmadan, tek bir artış uyarıyı tetikler. 5 dakikalık bir for, koşulun 5 sürekli dakika boyunca doğru olması gerektiği anlamına gelir. Bu, anlık dalgalanmalardan kaynaklanan gürültüyü ortadan kaldırır.
Uyarı Hijyeni#
Her uyarı bu testi geçmelidir:
- Eyleme geçirilebilir mi? Kimse bir şey yapamıyorsa, uyarı vermeyin. Loglayın, panoya koyun, ama birini uyandırmayın.
- İnsan müdahalesi gerektiriyor mu? Kendiliğinden düzeliyorsa (kısa bir ağ kesintisi gibi),
formaddesi onu filtrelemelidir. - Son 30 günde tetiklendi mi? Tetiklenmediyse, yanlış yapılandırılmış olabilir veya eşik yanlış olabilir. Gözden geçirin.
- Tetiklendiğinde insanlar önemsiyor mu? Ekip düzenli olarak reddediyorsa, kaldırın veya eşiği düzeltin.
Uyarılarımı üç ayda bir denetliyorum. Her uyarı üç sonuçtan birini alır: koru, eşiği ayarla veya sil.
Her Şeyi Bir Araya Getirmek: Express Uygulaması#
İşte tüm parçaların gerçek bir uygulamada nasıl bir araya geldiği:
// src/server.ts
import { initSentry } from "./lib/sentry";
// Sentry'yi ilk olarak başlat — diğer import'lardan önce
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);
// --- Ara Katman Sırası Önemli ---
// 1. Sentry istek işleyicisi (ilk olmalı)
app.use(sentryRequestHandler);
app.use(sentryTracingHandler);
// 2. Asenkron bağlam (istek kapsamlı bağlam oluşturur)
app.use(asyncContextMiddleware);
// 3. İstek loglama
app.use(requestLogger);
// 4. Metrik toplama
app.use(metricsMiddleware);
// 5. Gövde ayrıştırma
app.use(express.json({ limit: "1mb" }));
// --- Rotalar ---
// Sağlık kontrolleri (auth gerekmez)
app.use(createHealthRoutes(pool, redis));
// Metrikler (temel auth korumalı)
app.use(metricsRouter);
// API rotaları
app.use("/api", apiRouter);
// --- Hata İşleme ---
// Sentry hata işleyicisi (özel hata işleyiciden önce olmalı)
app.use(Sentry.Handlers.errorHandler());
// Özel hata işleyici (en son olmalı)
app.use(errorHandler);
// --- Başlat ---
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"
);
});
// Zarif kapanış
async function shutdown(signal: string) {
logger.info({ signal }, "Shutdown signal received");
// Yeni bağlantıları kabul etmeyi durdur
// Devam eden istekleri işle (Express bunu otomatik yapar)
// Veritabanı havuzunu kapat
await pool.end().catch((err) => {
logger.error({ err }, "Error closing database pool");
});
// Redis bağlantısını kapat
await redis.quit().catch((err) => {
logger.error({ err }, "Error closing Redis connection");
});
// Sentry'yi temizle
await Sentry.close(2000);
logger.info("Shutdown complete");
process.exit(0);
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));Minimal Uygulanabilir Yığın#
Yukarıdaki her şey "tam" yığındır. İlk günden hepsine ihtiyacınız yok. İşte projeniz büyüdükçe gözlemlenebilirliğinizi nasıl ölçeklendireceğiniz.
Aşama 1: Yan Proje / Tek Geliştirici#
Üç şeye ihtiyacınız var:
-
Yapılandırılmış konsol logları — Pino kullanın, stdout'a JSON çıktı verin. Sadece
pm2 logsile okuyor olsanız bile, JSON logları aranabilir ve ayrıştırılabilirdir. -
Bir /health uç noktası — Uygulaması 5 dakika sürer, "acaba çalışıyor mu?" diye hata ayıklarken kurtarır.
-
Sentry ücretsiz plan — Öngörmediğiniz hataları yakalar. Ücretsiz plan ayda 5.000 olay verir, yan proje için yeterlidir.
// Bu minimal kurulum. 50 satırın altında. Bahane yok.
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"));Aşama 2: Büyüyen Proje / Küçük Ekip#
Ekleyin:
-
Prometheus metrikleri + Grafana — "Yavaş hissediyorum" yetmediğinde ve veriye ihtiyacınız olduğunda. İstek oranı, hata oranı ve p99 gecikme ile başlayın.
-
Log toplama — Sunucuya
sshyapıp dosyalar arasındagrepyapmak artık yetmediğinde. Zaten Grafana kullanıyorsanız Loki + Promtail. -
Temel uyarılar — Hata oranı > %1, p99 > 1s, servis çökmüş. Üç uyarı. Hepsi bu.
Aşama 3: Üretim Servisi / Birden Fazla Servis#
Ekleyin:
-
OpenTelemetry ile dağıtık izleme — "API yavaş" ifadesi "çağırdığı 5 servisten hangisi yavaş?" olduğunda. OTel otomatik enstrümantasyonu, sıfır kod değişikliğiyle değerin %80'ini elde eder.
-
Kod olarak pano — Grafana panolarınızı sürüm kontrolü altına alın. Yeniden oluşturmanız gerektiğinde kendinize teşekkür edeceksiniz.
-
Yapılandırılmış uyarı — Uygun yönlendirme, eskalasyon ve susturma kurallarıyla AlertManager.
-
İş metrikleri — Saniyede sipariş, dönüşüm oranı, kuyruk derinliği. Ürün ekibinizin önem verdiği metrikler.
Neleri Atlayalım#
- Ana bilgisayar başına fiyatlandırma yapan APM satıcıları — Ölçekte maliyet absürt. Açık kaynak (Prometheus + Grafana + Tempo + Loki) işlevselliğin %95'ini verir.
- Üretimde INFO altındaki log seviyeleri — Terabaytlarca DEBUG logu üretir ve depolama için para ödersiniz. DEBUG'ı sadece aktif olarak sorun araştırırken kullanın, sonra kapatın.
- Her şey için özel metrikler — Her servis için RED yöntemiyle (Rate, Errors, Duration) başlayın. Sadece yanıtlanacak belirli bir sorunuz olduğunda özel metrikler ekleyin.
- Karmaşık iz örneklemesi — Basit bir örnekleme oranıyla başlayın (üretimde %10). Uyarlanabilir örnekleme çoğu ekip için erken optimizasyondur.
Son Düşünceler#
Gözlemlenebilirlik, satın aldığınız bir ürün veya kurduğunuz bir araç değildir. Bir pratiktir. Servisinizi işletmekle, servisinizin kendisini işletmesini ummak arasındaki farktır.
Burada anlattığım yığın — loglar için Pino, metrikler için Prometheus, izler için OpenTelemetry, hatalar için Sentry, görselleştirme için Grafana, uyarılar için AlertManager — mümkün olan en basit kurulum değildir. Ama her parça, diğer parçaların yanıtlayamadığı bir soruyu yanıtlayarak yerini hak eder.
Yapılandırılmış loglar ve bir sağlık uç noktasıyla başlayın. "Ne kadar kötü" bilmeniz gerektiğinde metrikler ekleyin. "Zaman nereye gidiyor" bilmeniz gerektiğinde izler ekleyin. Her katman bir öncekinin üzerine inşa edilir ve hiçbiri uygulamanızı yeniden yazmanızı gerektirmez.
Gözlemlenebilirlik eklemenin en iyi zamanı son üretim olayınızdan önceydi. İkinci en iyi zaman şimdi.