モニタリング レッスン5
総合演習
構造化ログ、Sentryエラー監視、カスタムメトリクスを統合した監視システムを構築しよう
演習の概要
この演習では、Express APIサーバーに包括的な監視システムを構築します。 レッスン1〜4で学んだ技術を組み合わせて、本番運用に耐えうる可観測性(Observability)を実装しましょう。
演習で構築するもの:
1. 構造化ログ(Winston)
- JSON形式のログ出力
- Correlation IDによるリクエスト追跡
- ログローテーション
2. エラー監視(Sentry)
- SDKの初期化とExpress統合
- ユーザーコンテキスト
- カスタムブレッドクラム
3. カスタムメトリクス(Prometheus)
- リクエスト数、エラー率
- レイテンシー(ヒストグラム)
- ビジネスメトリクス
4. アラート設定
- エラー率の閾値アラート
- レイテンシーの劣化検知Step 1: プロジェクトのセットアップ
まず必要なパッケージをインストールし、プロジェクト構成を準備します。
# プロジェクト初期化
mkdir monitoring-exercise && cd monitoring-exercise
npm init -y
npm install express typescript ts-node @types/express
npm install winston winston-daily-rotate-file
npm install @sentry/node
npm install prom-client
npm install uuid @types/uuid
# TypeScript設定
npx tsc --init
# ディレクトリ構成
monitoring-exercise/
├── src/
│ ├── index.ts # エントリポイント
│ ├── middleware/
│ │ ├── correlation.ts # Correlation IDミドルウェア
│ │ ├── metrics.ts # メトリクス収集ミドルウェア
│ │ └── logging.ts # リクエストログミドルウェア
│ ├── lib/
│ │ ├── logger.ts # Winston設定
│ │ ├── sentry.ts # Sentry設定
│ │ └── metrics.ts # Prometheusメトリクス定義
│ └── routes/
│ └── orders.ts # サンプルAPIルート
├── logs/ # ログ出力先
├── tsconfig.json
└── package.jsonStep 2: 構造化ロガーの実装
WinstonでJSON形式のログ出力を設定します。ログローテーションも含めて実装しましょう。
// src/lib/logger.ts
import winston from "winston";
import DailyRotateFile from "winston-daily-rotate-file";
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss.SSS" }),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: {
service: "monitoring-exercise",
environment: process.env.NODE_ENV || "development",
},
transports: [
new DailyRotateFile({
filename: "logs/error-%DATE%.log",
datePattern: "YYYY-MM-DD",
level: "error",
maxSize: "20m",
maxFiles: "14d",
zippedArchive: true,
}),
new DailyRotateFile({
filename: "logs/combined-%DATE%.log",
datePattern: "YYYY-MM-DD",
maxSize: "20m",
maxFiles: "14d",
zippedArchive: true,
}),
],
});
if (process.env.NODE_ENV !== "production") {
logger.add(
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
const metaStr = Object.keys(meta).length
? JSON.stringify(meta, null, 2)
: "";
return `${timestamp} [${level}] ${message} ${metaStr}`;
})
),
})
);
}
export default logger;Step 3: Correlation ID とリクエストログ
各リクエストにCorrelation IDを付与し、リクエスト/レスポンスのログを自動出力するミドルウェアを実装します。
// src/middleware/correlation.ts
import { v4 as uuidv4 } from "uuid";
import { Request, Response, NextFunction } from "express";
declare global {
namespace Express {
interface Request {
correlationId: string;
}
}
}
export function correlationMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
const correlationId =
(req.headers["x-correlation-id"] as string) || uuidv4();
req.correlationId = correlationId;
res.setHeader("x-correlation-id", correlationId);
next();
}
// src/middleware/logging.ts
import { Request, Response, NextFunction } from "express";
import logger from "../lib/logger";
export function requestLoggingMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
const start = process.hrtime.bigint();
logger.info("Incoming request", {
correlationId: req.correlationId,
method: req.method,
path: req.path,
query: req.query,
ip: req.ip,
userAgent: req.get("user-agent"),
});
res.on("finish", () => {
const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
const logLevel = res.statusCode >= 400 ? "warn" : "info";
logger[logLevel]("Request completed", {
correlationId: req.correlationId,
method: req.method,
path: req.path,
statusCode: res.statusCode,
durationMs: Math.round(durationMs * 100) / 100,
});
});
next();
}Step 4: Sentry エラー監視の統合
SentryをExpressアプリに統合し、エラーの自動キャプチャとカスタムコンテキストを設定します。
// src/lib/sentry.ts
import * as Sentry from "@sentry/node";
import { Express } from "express";
export function initSentry(app: Express) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV || "development",
release: "monitoring-exercise@1.0.0",
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
beforeSend(event) {
// 機密情報をフィルタリング
if (event.request?.headers) {
delete event.request.headers["authorization"];
delete event.request.headers["cookie"];
}
return event;
},
});
// Expressハンドラーの登録
app.use(Sentry.Handlers.requestHandler());
app.use(Sentry.Handlers.tracingHandler());
}
export function setupSentryErrorHandler(app: Express) {
app.use(Sentry.Handlers.errorHandler({
shouldHandleError(error) {
// 4xx と 5xx エラーをキャプチャ
const status = (error as any).status || 500;
return status >= 400;
},
}));
}
// Sentryにユーザー情報を設定するミドルウェア
export function sentryUserMiddleware(req: any, res: any, next: any) {
if (req.user) {
Sentry.setUser({
id: req.user.id,
email: req.user.email,
});
}
Sentry.setTag("correlationId", req.correlationId);
next();
}Step 5: Prometheus メトリクスの実装
カスタムメトリクスを定義し、リクエストごとに記録するミドルウェアを実装します。
// src/lib/metrics.ts
import client from "prom-client";
client.collectDefaultMetrics();
export const httpRequestsTotal = new client.Counter({
name: "http_requests_total",
help: "Total HTTP requests",
labelNames: ["method", "route", "status_code"],
});
export const httpRequestDuration = new client.Histogram({
name: "http_request_duration_seconds",
help: "HTTP request duration in seconds",
labelNames: ["method", "route"],
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});
export const ordersCreated = new client.Counter({
name: "orders_created_total",
help: "Total orders created",
labelNames: ["status"],
});
export const activeRequests = new client.Gauge({
name: "active_requests",
help: "Number of active requests",
});
export const metricsRegistry = client.register;
// src/middleware/metrics.ts
import { Request, Response, NextFunction } from "express";
import {
httpRequestsTotal,
httpRequestDuration,
activeRequests,
} from "../lib/metrics";
export function metricsMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
activeRequests.inc();
const end = httpRequestDuration.startTimer({
method: req.method,
route: req.route?.path || req.path,
});
res.on("finish", () => {
activeRequests.dec();
httpRequestsTotal.inc({
method: req.method,
route: req.route?.path || req.path,
status_code: res.statusCode,
});
end();
});
next();
}Step 6: すべてを統合する
最後に、すべてのコンポーネントを統合してExpressアプリを完成させます。 ミドルウェアの配置順序が重要です。
// src/index.ts
import express from "express";
import * as Sentry from "@sentry/node";
import { initSentry, setupSentryErrorHandler, sentryUserMiddleware } from "./lib/sentry";
import { correlationMiddleware } from "./middleware/correlation";
import { requestLoggingMiddleware } from "./middleware/logging";
import { metricsMiddleware } from "./middleware/metrics";
import { metricsRegistry, ordersCreated } from "./lib/metrics";
import logger from "./lib/logger";
const app = express();
const PORT = process.env.PORT || 3000;
// 1. Sentry初期化(最初に配置)
initSentry(app);
// 2. パーサー
app.use(express.json());
// 3. Correlation ID(ログの前に配置)
app.use(correlationMiddleware);
// 4. Sentryユーザー情報
app.use(sentryUserMiddleware);
// 5. リクエストログ
app.use(requestLoggingMiddleware);
// 6. メトリクス収集
app.use(metricsMiddleware);
// ── ルーティング ──
// メトリクスエンドポイント(Prometheusスクレイプ用)
app.get("/metrics", async (req, res) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});
// ヘルスチェック
app.get("/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// サンプルAPI: 注文作成
app.post("/api/orders", async (req, res) => {
const { items, userId } = req.body;
Sentry.addBreadcrumb({
category: "order",
message: "Creating new order",
level: "info",
data: { userId, itemCount: items?.length },
});
try {
// バリデーション
if (!items || items.length === 0) {
ordersCreated.inc({ status: "validation_error" });
throw Object.assign(new Error("Items are required"), { status: 400 });
}
// 注文処理(シミュレーション)
const order = {
id: "ord-" + Date.now(),
userId,
items,
total: items.reduce((sum: number, i: any) => sum + i.price, 0),
createdAt: new Date().toISOString(),
};
logger.info("Order created successfully", {
correlationId: req.correlationId,
orderId: order.id,
userId,
total: order.total,
itemCount: items.length,
});
ordersCreated.inc({ status: "success" });
res.status(201).json(order);
} catch (error: any) {
logger.error("Failed to create order", {
correlationId: req.correlationId,
userId,
error: error.message,
});
if (!error.status) {
ordersCreated.inc({ status: "error" });
}
throw error;
}
});
// 7. Sentryエラーハンドラー(ルーティングの後)
setupSentryErrorHandler(app);
// 8. カスタムエラーハンドラー
app.use((err: any, req: any, res: any, next: any) => {
const status = err.status || 500;
res.status(status).json({
error: status < 500 ? err.message : "Internal server error",
correlationId: req.correlationId,
});
});
// サーバー起動
app.listen(PORT, () => {
logger.info("Server started", { port: PORT });
});まとめ
- Winston で構造化ログを実装し、Correlation ID でリクエストを追跡する
- Sentry で未処理のエラーを自動キャプチャし、コンテキスト情報を付与する
- Prometheus でカスタムメトリクス(リクエスト数、レイテンシー、ビジネスメトリクス)を記録する
- ミドルウェアの配置順序に注意する(Sentry → Correlation ID → ログ → メトリクス)
- ヘルスチェックエンドポイントでサービスの生存確認を行う
- エラーハンドリングで機密情報をレスポンスに含めない
次のステップ
この演習を発展させて、Grafanaダッシュボードの構築、アラートルールの設定、 OpenTelemetryによるトレーシングの導入にも挑戦してみましょう。