monitoring-observability について
本番環境の可観測性を整えるエンジニア向けの実践スキルです。OpenTelemetry の計装、構造化ログ、トレース、SLO アラート、ダッシュボード、ヘルスチェックを扱い、信号の関連付けとユーザー影響に基づく通知を重視します。
主な機能
- 01OpenTelemetry の計装パターン
- 02トレースと連携する構造化ログ
- 03SLO ベースのアラート指針
- 04ヘルスチェックと準備状態の確認
- 05ダッシュボードとサンプリングの指針
活用シーン
- 01本番サービスへの可観測性の導入
- 02マイクロサービス間のリクエスト調査
- 03本番アラートのノイズ削減
- 04サービスダッシュボードとプローブの設計
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ travisjneuman/ .claude --skill monitoring-observabilityインストール後、Claude Code で/monitoring-observabilityを入力して読み込まれたか確認します。
詳細
モニタリングと可観測性
概要
このスキルでは、可観測性の3つの柱であるトレース、メトリクス、ログに加え、アラート、ダッシュボード、ヘルスチェックのパターンを扱います。ベンダーに依存しない標準としてのOpenTelemetry、クエリ可能な構造化ログ、マイクロサービスのデバッグに役立つ分散トレーシング、ノイズを減らすSLOベースのアラートに重点を置きます。
本番環境の可視性を高めるためのアプリケーション計装、モニタリング基盤の構築、分散システムのデバッグ、重要なアラートの設定、運用チームやプロダクトチーム向けのダッシュボード作成に、このスキルを使用してください。
基本原則
- 境界で計装する - すべての外部呼び出し(HTTP、データベース、キュー、キャッシュ)をトレースします。内部関数のトレースはノイズを増やしますが、境界でのトレースはシステムの動作を明らかにします。
- 非構造化より構造化を優先する - すべてのログエントリーを、相関 ID、サービス名、コンテキストを含むJSONにします。非構造化ログは、大規模環境では検索できません。
- 原因ではなく症状に対してアラートを出す - 特定のサーバーメトリクスが急上昇したときではなく、ユーザーに影響が出たとき(エラー率、レイテンシのSLO違反)にアラートを出します。症状ベースのアラートにより、ノイズを80%削減できます。
- シグナルを相関させる - 1つのリクエストに関するログ、トレース、メトリクスをつなぐトレース IDを設定します。相関がなければ、分散システムの問題のデバッグは推測に頼ることになります。
- エラー率の予算を管理する - SLOを定義します(可用性99.9%の場合、ダウンタイムの予算は月43分です)。個々のエラーではなく、エラーバジェットの消費速度が速すぎるときにアラートを出します。
主なパターン
パターン1:OpenTelemetryによる計装(Node.js)
使用する場面: トレース、メトリクス、ログの相関が必要な、あらゆる本番環境のNode.jsサービス。
実装:
// tracing.ts - Must be imported BEFORE any other module
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,
ATTR_DEPLOYMENT_ENVIRONMENT,
} from "@opentelemetry/semantic-conventions";
const sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: process.env.SERVICE_NAME ?? "my-service",
[ATTR_SERVICE_VERSION]: process.env.SERVICE_VERSION ?? "0.0.0",
[ATTR_DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV ?? "development",
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://localhost:4318/v1/traces",
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://localhost:4318/v1/metrics",
}),
exportIntervalMillis: 30_000,
}),
instrumentations: [
getNodeAutoInstrumentations({
// Disable noisy fs instrumentation
"@opentelemetry/instrumentation-fs": { enabled: false },
// Configure HTTP to capture request/response headers
"@opentelemetry/instrumentation-http": {
requestHook: (span, request) => {
span.setAttribute("http.request.header.x-request-id",
request.headers?.["x-request-id"] ?? "unknown"
);
},
},
}),
],
});
sdk.start();
process.on("SIGTERM", () => {
sdk.shutdown().then(() => process.exit(0));
});
// Custom span creation for business logic
import { trace, SpanStatusCode, context } from "@opentelemetry/api";
const tracer = trace.getTracer("order-service");
async function processOrder(orderId: string): Promise<Order> {
return tracer.startActiveSpan("process_order", async (span) => {
try {
span.setAttribute("order.id", orderId);
const order = await tracer.startActiveSpan("fetch_order", async (fetchSpan) => {
const result = await db.orders.findUnique({ where: { id: orderId } });
fetchSpan.setAttribute("order.total", result?.total ?? 0);
fetchSpan.end();
return result;
});
if (!order) {
span.setStatus({ code: SpanStatusCode.ERROR, message: "Order not found" });
throw new OrderNotFoundError(orderId);
}
await tracer.startActiveSpan("charge_payment", async (paymentSpan) => {
paymentSpan.setAttribute("payment.amount", order.total);
await paymentService.charge(order);
paymentSpan.end();
});
span.setStatus({ code: SpanStatusCode.OK });
return order;
} catch (error) {
span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
span.recordException(error as Error);
throw error;
} finally {
span.end();
}
});
}
理由: OpenTelemetryはベンダーに依存しない計装を提供します。自動計装により、HTTP、データベース、gRPCの呼び出しを自動的に取得できます。カスタムスパンを追加すると、ビジネスコンテキスト(注文 ID、決済金額など)が含まれ、デバッグに役立つトレースになります。
パターン2:相関情報を含む構造化ログ
使用する場面: ログを出力するすべてのアプリケーション(つまり、すべてのアプリケーション)。
実装:
import pino from "pino";
import { context, trace } from "@opentelemetry/api";
// Create logger with trace correlation
const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
formatters: {
level: (label) => ({ level: label }),
},
mixin() {
// Automatically inject trace context into every log line
const span = trace.getSpan(context.active());
if (span) {
const spanContext = span.spanContext();
return {
traceId: spanContext.traceId,
spanId: spanContext.spanId,
traceFlags: spanContext.traceFlags,
};
}
return {};
},
// Redact sensitive fields
redact: ["req.headers.authorization", "password", "token", "apiKey"],
});
// Usage - always log structured data, not string interpolation
logger.info({ orderId, userId, total: order.total }, "Order processed successfully");
// NOT this:
// logger.info(`Order ${orderId} processed for user ${userId} with total ${order.total}`);
// Error logging with context
logger.error(
{
err: error,
orderId,
operation: "payment_charge",
paymentProvider: "stripe",
},
"Payment processing failed"
);
// Child loggers for request-scoped context
function createRequestLogger(req: Request) {
return logger.child({
requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
path: req.url,
method: req.method,
userAgent: req.headers.get("user-agent"),
});
}
理由: 構造化ログはクエリ可能です。orderIdでフィルターしたり、traceIdを使ってトレースと相関させたり、operationごとにエラー率を集計したりできます。文字列補間によるログからフィールドを抽出するには正規表現が必要ですが、大規模環境ではうまく機能しません。
パターン3:SLOベースのアラート
使用する場面: ノイズを減らし、ユーザーへの影響を重視する本番環境のアラートを設定するとき。
実装:
# Prometheus alerting rules based on SLOs
groups:
- name: slo-alerts
rules:
# Availability SLO: 99.9% success rate
# Alert when burning through error budget too fast
- alert: HighErrorBudgetBurn
expr: |
(
sum(rate(http_requests_total{status=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))
) > (1 - 0.999) * 14.4
for: 5m
labels:
severity: critical
annotations:
summary: "Error budget burn rate is 14.4x (will exhaust in 1 hour)"
dashboard: "https://grafana.internal/d/slo-overview"
# Latency SLO: 99% of requests under 500ms
- alert: HighLatencyBudgetBurn
expr: |
(
sum(rate(http_request_duration_seconds_bucket{le="0.5"}[5m]))
/
sum(rate(http_request_duration_seconds_count[5m]))
) < 0.99
for: 10m
labels:
severity: warning
annotations:
summary: "Latency SLO breach: >1% of requests exceeding 500ms"
# Multi-window multi-burn-rate alert (Google SRE book pattern)
- alert: SLOBreach_MultiWindow
expr: |
(
error_ratio:rate1h > (14.4 * 0.001)
and
error_ratio:rate5m > (14.4 * 0.001)
)
or
(
error_ratio:rate6h > (6 * 0.001)
and
error_ratio:rate30m > (6 * 0.001)
)
labels:
severity: critical
// Application-level SLO tracking with custom metrics
import { metrics } from "@opentelemetry/api";
const meter = metrics.getMeter("slo-metrics");
const requestCounter = meter.createCounter("http.requests.total", {
description: "Total HTTP requests",
});
const requestDuration = meter.createHistogram("http.request.duration", {
description: "HTTP request duration in seconds",
unit: "s",
});
// Middleware to track SLO metrics
function sloMiddleware(req: Request, res: Response, next: NextFunction) {
const start = performance.now();
res.on("finish", () => {
const duration = (performance.now() - start) / 1000;
const attributes = {
method: req.method,
route: req.route?.path ?? "unknown",
status: String(res.statusCode),
success: String(res.statusCode < 500),
};
requestCounter.add(1, attributes);
requestDuration.record(duration, attributes);
});
next();
}
理由: 従来のしきい値ベースのアラート(CPU > 80%、エラー > 10)はノイズを発生させます。SLOベースのアラートでは、「ユーザーに影響が出ているか」「エラーバジェットはどのくらいの速さで消費されているか」を確認します。この方法では、対応が必要なときだけページャー通知が送られます。
パターン4:ヘルスチェックとレディネスプローブ
使用する場面: Kubernetesにデプロイされる、またはロードバランサーの背後で実行されるすべてのサービス。
実装:
import { Router } from "express";
interface HealthCheckResult {
status: "healthy" | "degraded" | "unhealthy";
checks: Record<string, {
status: "pass" | "fail" | "warn";
latencyMs: number;
message?: string;
}>;
uptime: number;
version: string;
}
const healthRouter = Router();
// Liveness probe - is the process alive?
// Should NEVER check dependencies. Only checks if the process can respond.
healthRouter.get("/healthz", (req, res) => {
res.status(200).json({ status: "alive" });
});
// Readiness probe - can this instance serve traffic?
// Checks critical dependencies.
healthRouter.get("/readyz", async (req, res) => {
const checks: HealthCheckResult["checks"] = {};
// Check database
const dbStart = performance.now();
try {
await db.$queryRaw`SELECT 1`;
checks.database = { status: "pass", latencyMs: performance.now() - dbStart };
} catch (err) {
checks.database = {
status: "fail",
latencyMs: performance.now() - dbStart,
message: (err as Error).message,
};
}
// Check Redis
const redisStart = performance.now();
try {
await redis.ping();
checks.redis = { status: "pass", latencyMs: performance.now() - redisStart };
} catch (err) {
checks.redis = {
status: "fail",
latencyMs: performance.now() - redisStart,
message: (err as Error).message,
};
}
const allPassing = Object.values(checks).every((c) => c.status === "pass");
const anyFailing = Object.values(checks).some((c) => c.status === "fail");
const result: HealthCheckResult = {
status: anyFailing ? "unhealthy" : allPassing ? "healthy" : "degraded",
checks,
uptime: process.uptime(),
version: process.env.SERVICE_VERSION ?? "unknown",
};
res.status(anyFailing ? 503 : 200).json(result);
});
理由: Kubernetesは、スタックしたプロセスの再起動にライブネスプローブを使い、準備ができていないインスタンスへのトラフィック送信を停止するためにレディネスプローブを使います。設定を誤ると連鎖障害を引き起こします。たとえば、データベースを確認するライブネスプローブを設定すると、データベース障害中に正常なPodが再起動され、状況が悪化します。
Grafanaダッシュボード クイックリファレンス
{
"panels": [
{
"title": "Request Rate",
"type": "timeseries",
"targets": [{ "expr": "sum(rate(http_requests_total[5m])) by (status)" }]
},
{
"title": "Error Rate (%)",
"type": "stat",
"targets": [{
"expr": "sum(rate(http_requests_total{status=~\"5..\"}[5m])) / sum(rate(http_requests_total[5m])) * 100"
}],
"thresholds": [
{ "value": 0, "color": "green" },
{ "value": 0.1, "color": "yellow" },
{ "value": 1, "color": "red" }
]
},
{
"title": "P99 Latency",
"type": "timeseries",
"targets": [{
"expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route))"
}]
}
]
}
アンチパターン
| アンチパターン | 問題点 | より良い方法 |
|---|---|---|
本番環境で console.log を使ってログを記録する | 構造化されず、関連付けもできず、ログレベルもありません | JSON 出力に対応した pino または winston を使用します |
| すべての 5xx エラーでアラートを出す | アラート疲れを招き、チームがアラートを無視するようになります | SLO の違反/エラーバジェットの消費率に基づいてアラートを出します |
| Liveness プローブでデータベースを確認する | DB の障害中にポッドが再起動し、連鎖障害が発生します | Liveness はプロセスのみを確認し、Readiness は依存関係を確認します |
| トレースコンテキストを伝播しない | サービスをまたいでリクエストを追跡できません | W3C traceparent ヘッダーを使用し、すべてのクライアントに注入します |
| すべてのトレースをサンプリングする | 規模の拡大に伴い、ストレージコストが急増します | ヘッドベースのサンプリング(10-20%)、またはエラーに対するテールベースのサンプリングを使用します |
| PII(メールアドレス、IP アドレス)をログに記録する | GDPR/プライバシー違反になります | ロガーの設定で機密フィールドをマスキングします |
| 50 個のパネルがあるダッシュボード | 情報過多になり、読み込みも遅くなります | 4 つのゴールデンシグナル(レート、エラー、継続時間、飽和度)を使用します |
チェックリスト
- OpenTelemetry SDK をほかのすべてのインポートより前に初期化する
- HTTP、データベース、キューのクライアントで自動計装を有効にする
- ビジネスロジックの境界に、関連する属性を持つカスタムスパンを追加する
- トレース ID と関連付けた構造化 JSON ログを使用する
- ロガーの設定で機密フィールドをマスキングする
- Liveness プローブ:プロセスのみを確認する(依存関係は確認しない)
- Readiness プローブ:すべての重要な依存関係を確認する
- 可用性とレイテンシの SLO を定義する
- 生のしきい値ではなく、エラーバジェットの消費率に基づいてアラートを設定する
- 各サービスに 4 つのゴールデンシグナルを表示するダッシュボードを用意する
- 本番環境の規模に応じたトレースサンプリング戦略を設定する
- ログ集約パイプラインから集中管理ストアにログを送信する
関連リソース
- スキル:
performance-engineering(レイテンシの最適化)、application-security(セキュリティログ) - ルール:
docs/reference/stacks/fullstack-nextjs-nestjs.md(NestJS の計装パターン) - ルール:
docs/reference/tooling/troubleshooting.md(ログを使ったデバッグ)