構造化ログ(JSON)とCorrelation ID設計|Pino/Winston + Loki/Grafana 実装ガイド
- 本番ログは1行1 JSONに統一し、requestId(correlation id)を Express ミドルウェアで生成→→child logger→下流 へ伝播する。
- Pino(新規)または Winston format.json()(既存)で stdout に出し、Promtail → Loki → Grafana で
{service="api"} | json | requestId="..."と追跡する。 - 分散トレーシングを本格化する段階では OpenTelemetry による分散トレーシング で trace id をログに注入し、ログとトレースを同一 requestId で突合する。
構造化ログ JSON とは
構造化ログ(Structured Logging) とは、ログメッセージを人間が読む自由文ではなく、パース可能なデータ形式(多くは JSON) として出力する手法です。1行が1つの JSON オブジェクトになり、フィールド名と型が安定しているため、ログ基盤(Loki、CloudWatch Logs Insights、Datadog 等)が機械的に検索・集計・可視化できます。
従来のプレーンテキストログの例です。
2026-06-21T10:15:32.123Z ERROR [api] request failed userId=42 requestId=7f3a duration=812ms path=/orders
これは人間には読みやすい一方、正規表現で userId を抜くとフォーマット変更で壊れやすく、数値の 812 と文字列 "812ms" の区別も曖昧です。
構造化ログ JSON の同内容は次のとおりです。
{
"level": "error",
"time": "2026-06-21T10:15:32.123Z",
"service": "api",
"env": "production",
"msg": "request failed",
"userId": 42,
"requestId": "7f3a9c2e-4b1d-4e8f-a3c5-9d2e1f0a8b7c",
"durationMs": 812,
"path": "/orders",
"method": "POST",
"statusCode": 503
}
Loki の LogQL では | json でフィールドを抽出し、durationMs > 500 や statusCode >= 500 をフィルタできます。構造化ログ JSON は「ログをデータとして扱う」ための前提条件であり、マイクロサービスや Kubernetes 環境では事実上の標準です。
なぜプレーンテキストから移行するのか
移行の動機は大きく4つに分けられます。
| 課題 | プレーンテキスト | 構造化ログ JSON |
|---|---|---|
| 検索 | grep と正規表現に依存 | フィールド指定クエリ(LogQL, SQL) |
| 集計 | スクリプトで都度パース | rate(), avg(durationMs) がそのまま使える |
| 相関 | requestId の文字列一致が不安定 | 同一キーで API・DB・外部呼び出しを横断 |
| ツール連携 | カスタムパーサーが必要 | ECS/OpenTelemetry ログモデルへ拡張しやすい |
障害時に「本番だけ再現しない」バグの9割は、そのリクエストに何が起きたかをログから復元できないことが原因です。構造化ログと correlation id は、SRE が夜中に Loki を開いて 5 分で原因箇所を特定するための投資です。メトリクス(Prometheus)が「何がおかしいか」を、ログが「なぜおかしいか」を教えます。
ログスキーマ設計:必須フィールドと命名規則
チーム内でスキーマを固定しないと、user_id / userId / uid が混在し、LogQL が使い物になりません。最低限次のフィールドを全サービス共通にしてください。
| フィールド | 型 | 説明 |
|---|---|---|
time / @timestamp | ISO8601 string | イベント発生時刻(UTC 推奨) |
level | string | debug / info / warn / error / fatal |
msg | string | 人間向け短い説明(Pino は msg が慣例) |
service | string | api, worker, billing 等 |
env | string | production, staging, development |
requestId | string | correlation id(UUID v4 推奨) |
traceId | string | (任意)OpenTelemetry trace id |
spanId | string | (任意)OpenTelemetry span id |
userId | string | number | (任意)認証済みユーザー |
durationMs | number | (任意)処理時間 |
err | object | (任意)エラー時 { type, message, stack } |
命名は camelCase(JavaScript エコシステム)か snake_case(Go/Python 混在)のどちらか一方に統一します。Loki のラベルには 高カーディナリティな値(requestId, userId)を載せない こと。ラベルは {service="api", env="production"} 程度に留め、requestId は JSON フィールドとしてクエリします。requestId をラベルにするとシリーズ爆発で Loki が不安定になります。
Correlation ID と分散トレーシング
Correlation ID(本記事では requestId と同義で扱います)は、1つのユーザー操作に紐づく一意の識別子です。ブラウザ → API ゲートウェイ → 注文サービス → 決済サービス → ワーカーと、複数プロセスをまたいでも同じ ID をログに載せることで、Loki 上で時系列を再構成できます。
HTTP ヘッダーでの伝播
業界で広く使われるヘッダー名は次のとおりです。
| ヘッダー | 用途 |
|---|---|
X-Request-Id | シンプルな correlation id(AWS ALB 等も生成) |
X-Correlation-Id | 同義。企業標準でこちらを使う例も多い |
traceparent | W3C Trace Context(OpenTelemetry 標準) |
入口(API ゲートウェイまたは最初の Express)で ID がなければ生成し、レスポンスヘッダーにも同じ値を返します。クライアントが X-Request-Id を送ってきた場合はそれを尊重し、サポート問い合わせ時にユーザーが ID を伝えられるようにします。
correlation id 分散トレーシングの最小構成
本格的な 分散トレーシング(Jaeger / Tempo / X-Ray)までは不要な段階でも、次の3点だけで障害調査効率は大きく上がります。
- 各サービスで requestId をログフィールドに含める
- サービス間 HTTP で
X-Request-Idを転送する - Loki / Grafana で
{service=~".+"} | json | requestId="..."で横断検索する
OpenTelemetry を導入すると、trace id / span id が自動付与され、ログとトレースビューをリンクできます。詳細は OpenTelemetry による分散トレーシング を参照してください(関連記事)。
クライアントまたは ALB が X-Request-Id を付与(無ければ API が UUID を生成)
Express ミドルウェアが requestId を AsyncLocalStorage に保存
Pino child logger に requestId を bind し、全 log 呼び出しに自動付与
axios/fetch で下流サービスへ同じ X-Request-Id を転送
ワーカー(SQS 等)はメッセージ属性に requestId を載せて非同期処理でも追跡
Grafana Explore で requestId 検索し、エラー行の直前の info ログから原因を特定
Express:requestId ミドルウェアの完全実装
Express アプリでは、最初のミドルウェアで requestId を確定させます。crypto.randomUUID()(Node 16.7+)を使い、クライアント送信値は長さ・形式を検証してから採用します。
requestContext.ts — AsyncLocalStorage
// src/lib/requestContext.ts
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestContext {
requestId: string;
userId?: string;
path?: string;
method?: string;
}
export const requestStorage = new AsyncLocalStorage<RequestContext>();
export function getRequestContext(): RequestContext | undefined {
return requestStorage.getStore();
}
export function getRequestId(): string | undefined {
return requestStorage.getStore()?.requestId;
}
requestIdMiddleware.ts
// src/middleware/requestIdMiddleware.ts
import type { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'node:crypto';
import { requestStorage, type RequestContext } from '../lib/requestContext.js';
const REQUEST_ID_HEADER = 'x-request-id';
const MAX_HEADER_LENGTH = 128;
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function normalizeIncomingRequestId(raw: string | undefined): string {
if (!raw) return randomUUID();
const trimmed = raw.trim().slice(0, MAX_HEADER_LENGTH);
// UUID 形式ならそのまま、それ以外も許容するなら英数字のみに制限
if (UUID_REGEX.test(trimmed)) return trimmed;
if (/^[a-zA-Z0-9_-]+$/.test(trimmed)) return trimmed;
return randomUUID();
}
export function requestIdMiddleware(
req: Request,
res: Response,
next: NextFunction,
): void {
const incoming = req.headers[REQUEST_ID_HEADER];
const headerValue = Array.isArray(incoming) ? incoming[0] : incoming;
const requestId = normalizeIncomingRequestId(headerValue);
req.requestId = requestId;
res.setHeader('X-Request-Id', requestId);
const ctx: RequestContext = {
requestId,
path: req.path,
method: req.method,
};
requestStorage.run(ctx, () => next());
}
// Express の型拡張
declare global {
namespace Express {
interface Request {
requestId: string;
}
}
}
httpLoggerMiddleware.ts — リクエスト完了ログ
// src/middleware/httpLoggerMiddleware.ts
import type { Request, Response, NextFunction } from 'express';
import { logger } from '../lib/logger.js';
export function httpLoggerMiddleware(
req: Request,
res: Response,
next: NextFunction,
): void {
const start = process.hrtime.bigint();
res.on('finish', () => {
const durationMs = Number(process.hrtime.bigint() - start) / 1_000_000;
const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info';
logger[level]({
requestId: req.requestId,
method: req.method,
path: req.originalUrl,
statusCode: res.statusCode,
durationMs: Math.round(durationMs * 100) / 100,
userAgent: req.headers['user-agent'],
ip: req.ip,
}, 'request completed');
});
next();
}
app.ts — ミドルウェアの登録順
// src/app.ts
import express from 'express';
import { requestIdMiddleware } from './middleware/requestIdMiddleware.js';
import { httpLoggerMiddleware } from './middleware/httpLoggerMiddleware.js';
import { ordersRouter } from './routes/orders.js';
const app = express();
app.disable('x-powered-by');
app.use(express.json({ limit: '1mb' }));
// ★ 最初に requestId — 以降のすべての log が ID を参照できる
app.use(requestIdMiddleware);
app.use(httpLoggerMiddleware);
app.use('/orders', ordersRouter);
app.use((err: Error, req: express.Request, res: express.Response, _next: express.NextFunction) => {
const { logger } = require('./lib/logger.js');
logger.error({ err, requestId: req.requestId }, 'unhandled error');
res.status(500).json({ error: 'Internal Server Error', requestId: req.requestId });
});
export { app };
requestIdMiddleware は body parser より前でも後でも動きますが、ルーターより必ず前に置いてください。httpLoggerMiddleware は requestIdMiddleware の直後が理想です。認証ミドルウェアで userId が確定したら requestStorage.getStore() に追記するか、child logger を再生成します。
下流 HTTP への correlation id 伝播
API が別マイクロサービスを呼ぶとき、X-Request-Id を付け忘れるとログの連鎖が切れます。
fetch ラッパー
// src/lib/httpClient.ts
import { getRequestId } from './requestContext.js';
import { logger } from './logger.js';
export async function serviceFetch(
url: string,
init: RequestInit = {},
): Promise<Response> {
const requestId = getRequestId();
const headers = new Headers(init.headers);
if (requestId) {
headers.set('X-Request-Id', requestId);
}
const start = Date.now();
let res: Response;
try {
res = await fetch(url, { ...init, headers });
} catch (err) {
logger.error({ err, url, requestId }, 'downstream fetch failed');
throw err;
}
logger.info({
requestId,
url,
statusCode: res.status,
durationMs: Date.now() - start,
}, 'downstream fetch completed');
return res;
}
axios インターセプター
// src/lib/axiosClient.ts
import axios from 'axios';
import { getRequestId } from './requestContext.js';
import { logger } from './logger.js';
export const axiosClient = axios.create({
timeout: 10_000,
});
axiosClient.interceptors.request.use((config) => {
const requestId = getRequestId();
if (requestId) {
config.headers.set('X-Request-Id', requestId);
}
return config;
});
axiosClient.interceptors.response.use(
(response) => {
logger.debug({
requestId: getRequestId(),
url: response.config.url,
statusCode: response.status,
}, 'axios response');
return response;
},
(error) => {
logger.error({
err: error,
requestId: getRequestId(),
url: error.config?.url,
statusCode: error.response?.status,
}, 'axios error');
return Promise.reject(error);
},
);
SQS / 非同期ジョブ
HTTP が切れる境界では、メッセージ属性に requestId を載せます。
// src/lib/enqueueJob.ts
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
import { getRequestId } from './requestContext.js';
const sqs = new SQSClient({});
export async function enqueueOrderJob(orderId: string): Promise<void> {
const requestId = getRequestId() ?? 'unknown';
await sqs.send(new SendMessageCommand({
QueueUrl: process.env.ORDER_QUEUE_URL!,
MessageBody: JSON.stringify({ orderId }),
MessageAttributes: {
requestId: { DataType: 'String', StringValue: requestId },
},
}));
}
ワーカー側では属性を読み、requestStorage.run({ requestId }, () => processJob()) で同じコンテキストを復元します。
Pino:本番向け JSON ログ設定
Pino は Node.js 向けの高速 JSON ロガーです。非同期書き込みと低シリアライズコストで、リクエスト処理のオーバーヘッドを抑えられます。
logger.ts(本番設定)
// src/lib/logger.ts
import pino from 'pino';
import { getRequestContext } from './requestContext.js';
const isProduction = process.env.NODE_ENV === 'production';
export const rootLogger = pino({
level: process.env.LOG_LEVEL ?? (isProduction ? 'info' : 'debug'),
base: {
service: process.env.SERVICE_NAME ?? 'api',
env: process.env.NODE_ENV ?? 'development',
pid: process.pid,
},
timestamp: pino.stdTimeFunctions.isoTime,
formatters: {
level(label) {
return { level: label };
},
},
redact: {
paths: [
'password',
'req.headers.authorization',
'req.headers.cookie',
'body.password',
'body.token',
'body.creditCard',
'*.secret',
],
censor: '[REDACTED]',
},
...(isProduction
? {}
: {
transport: {
target: 'pino-pretty',
options: { colorize: true, translateTime: 'SYS:standard' },
},
}),
});
/** リクエストコンテキストの requestId を自動付与するラッパー */
function withContext(bindings: Record<string, unknown> = {}) {
const ctx = getRequestContext();
const merged = ctx?.requestId ? { requestId: ctx.requestId, ...bindings } : bindings;
return rootLogger.child(merged);
}
export const logger = {
debug: (obj: Record<string, unknown>, msg?: string) =>
withContext(obj).debug(obj, msg),
info: (obj: Record<string, unknown>, msg?: string) =>
withContext(obj).info(obj, msg),
warn: (obj: Record<string, unknown>, msg?: string) =>
withContext(obj).warn(obj, msg),
error: (obj: Record<string, unknown>, msg?: string) =>
withContext(obj).error(obj, msg),
child: (bindings: Record<string, unknown>) => withContext(bindings),
};
ルートハンドラでの使い方
// src/routes/orders.ts
import { Router } from 'express';
import { logger } from '../lib/logger.js';
import { serviceFetch } from '../lib/httpClient.js';
export const ordersRouter = Router();
ordersRouter.post('/', async (req, res) => {
const orderLogger = logger.child({ userId: req.body.userId, path: '/orders' });
orderLogger.info({ body: { sku: req.body.sku, qty: req.body.qty } }, 'creating order');
try {
const inventoryRes = await serviceFetch(
`${process.env.INVENTORY_URL}/reserve`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: req.body.sku, qty: req.body.qty }),
},
);
if (!inventoryRes.ok) {
orderLogger.warn({ statusCode: inventoryRes.status }, 'inventory reservation failed');
return res.status(502).json({ error: 'inventory unavailable', requestId: req.requestId });
}
orderLogger.info({ orderId: 'ord_123' }, 'order created');
return res.status(201).json({ orderId: 'ord_123', requestId: req.requestId });
} catch (err) {
orderLogger.error({ err }, 'order creation failed');
return res.status(500).json({ error: 'internal error', requestId: req.requestId });
}
});
Pino の err キーに Error オブジェクトを渡すと、err.type / err.message / err.stack が自動シリアライズされます。logger.error({ err }, 'message') 形式を徹底してください。
Winston:JSON 出力と correlation id
既存プロジェクトで Winston を使い続ける場合も、format.json() で 構造化ログ JSON に統一できます。
winstonLogger.ts
// src/lib/winstonLogger.ts
import winston from 'winston';
import { getRequestContext } from './requestContext.js';
const { combine, timestamp, json, errors, printf } = winston.format;
const addRequestContext = winston.format((info) => {
const ctx = getRequestContext();
if (ctx?.requestId) {
info.requestId = ctx.requestId;
}
return info;
});
export const winstonLogger = winston.createLogger({
level: process.env.LOG_LEVEL ?? 'info',
defaultMeta: {
service: process.env.SERVICE_NAME ?? 'api',
env: process.env.NODE_ENV ?? 'development',
},
format: combine(
errors({ stack: true }),
timestamp({ format: () => new Date().toISOString() }),
addRequestContext(),
json(),
),
transports: [
new winston.transports.Console(),
],
});
// 開発時のみ人間可読
if (process.env.NODE_ENV !== 'production') {
winstonLogger.clear();
winstonLogger.add(new winston.transports.Console({
format: combine(
addRequestContext(),
errors({ stack: true }),
timestamp(),
printf(({ level, message, timestamp, requestId, ...meta }) => {
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
const rid = requestId ? ` [${requestId}]` : '';
return `${timestamp} ${level}${rid}: ${message}${metaStr}`;
}),
),
}));
}
Morgan との併用(移行期)
// src/middleware/morganStream.ts
import morgan from 'morgan';
import { winstonLogger } from '../lib/winstonLogger.js';
const stream = {
write: (message: string) => {
winstonLogger.info({ msg: message.trim(), kind: 'access' });
},
};
export const morganMiddleware = morgan(
':method :url :status :res[content-length] - :response-time ms :req[x-request-id]',
{ stream },
);
移行が完了したら Morgan を httpLoggerMiddleware に置き換え、access log も同一 JSON スキーマに統一することを推奨します。
| 観点 | Pino / Winston |
|---|---|
| パフォーマンス | Pino が有利。高 RPS API では差が効く |
| JSON デフォルト | Pino は最初から JSON。Winston は format 設定が必要 |
| エコシステム | Winston transport が豊富。Pino は pino-http / pino-pretty |
| child logger | 両方対応。requestId bind パターンは同一 |
| 機密 redact | Pino 組み込み redact。Winston は custom format で同等実装 |
Loki / Grafana アーキテクチャ
Grafana Loki は、Prometheus にインスパイアされたラベルベースのログ集約システムです。ログ本文はオブジェクトストレージ(S3, GCS, ローカル)に置き、インデックスはラベルのみ——という設計で、Elasticsearch より運用コストを抑えやすいのが特徴です。
典型的な構成は次のとおりです。
[Node.js App] --stdout JSON--> [Docker / K8s]
|
v
[Promtail]
|
v
[Loki] <-----> [Grafana]
^
[Alertmanager](任意)
- Promtail: 各ノードのログファイルまたは Docker ログを tail し Loki に push
- Loki: ログ保存と LogQL クエリ API
- Grafana: Explore( ad hoc 検索)とダッシュボード
Docker Compose:Loki + Promtail + Grafana
ローカル検証用の最小スタック例です。
# docker-compose.observability.yml
services:
loki:
image: grafana/loki:3.0.0
command: -config.file=/etc/loki/local-config.yaml
ports:
- "3100:3100"
volumes:
- loki-data:/loki
promtail:
image: grafana/promtail:3.0.0
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./promtail-config.yml:/etc/promtail/config.yml
command: -config.file=/etc/promtail/config.yml
grafana:
image: grafana/grafana:11.0.0
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_AUTH_ANONYMOUS_ENABLED=true
volumes:
- grafana-data:/var/lib/grafana
volumes:
loki-data:
grafana-data:
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: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
# コンテナ名を service ラベルに(低カーディナリティ)
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: service
- source_labels: ['__meta_docker_container_log_stream']
target_label: stream
pipeline_stages:
# アプリが stdout に JSON を出している前提
- json:
expressions:
level: level
service: service
env: env
- labels:
level:
service:
env:
pipeline_stages で JSON から level / service / env をラベル化すると、LogQL の {service="api", level="error"} が高速になります。requestId はラベル化しないでください。
Grafana 初回設定: Configuration → Data sources → Add Loki、URL に http://loki:3100(Compose ネットワーク内)または http://localhost:3100(ホストから)。
LogQL クエリ例:実務で毎日使うパターン
LogQL は Log Query Language で、ラベルセレクタとパイプラインでログを絞り込みます。
1. 特定 requestId の全ログ(correlation id 分散トレーシングの基本)
{service=~"api|inventory|billing"} | json | requestId="7f3a9c2e-4b1d-4e8f-a3c5-9d2e1f0a8b7c"
複数サービスにまたがる1リクエストの流れを時系列表示します。Grafana Explore で Time 昇順、Dedup なしが読みやすいです。
2. 直近15分のエラーログ
{service="api", level="error"} | json | line_format "{{.time}} {{.msg}} req={{.requestId}}"
3. 遅いリクエスト(durationMs > 1000)
{service="api"} | json | durationMs > 1000 | line_format "{{.method}} {{.path}} {{.durationMs}}ms id={{.requestId}}"
4. 5xx 率(ログベース近似)
sum(rate({service="api"} | json | statusCode >= 500 [5m]))
/
sum(rate({service="api"} | json [5m]))
メトリクスは Prometheus が本命ですが、access log に statusCode があればログだけでも概算できます。
5. パス別 p95 レイテンシ(Experimental)
quantile_over_time(0.95,
{service="api"} | json | unwrap durationMs [5m]
) by (path)
unwrap は数値フィールドをメトリクスとして扱う機能です。Loki 3.x 以降で利用可能。本番 SLO 監視は Prometheus histogram の方が安定しますが、ログだけの段階では有用です。
6. 特定ユーザーの操作履歴
{service="api"} | json | userId="42" | line_format "{{.time}} {{.msg}}"
個人情報ログの保持期間とアクセス権限(RBAC)に注意してください。
7. エラーメッセージの集計
topk(10,
sum by (msg) (count_over_time({service="api", level="error"} | json [1h]))
)
同じ msg が急増していないかを監視し、デプロイ後の回帰検知に使います。
| json は各行を JSON としてパースします。1行に複数 JSON や JSON 以外の行 が混ざるとパースエラー行が増えます。構造化ログ JSON を stdout の唯一の形式にし、console.log を禁止する ESLint ルールを検討してください。
Grafana ダッシュボードとアラート
Explore からダッシュボードへ
- Grafana → Explore → データソース Loki
- 上記 LogQL を入力し、結果を確認
- Add to dashboard でパネル化
推奨パネル構成:
| パネル | クエリ概要 |
|---|---|
| Error rate | {service="api", level="error"} の rate |
| Top slow paths | durationMs unwrap + quantile_over_time |
| Recent 5xx | statusCode >= 500 のログ一覧 |
| Live tail | {service="api"} | json の Live モード |
アラート例(Loki Ruler)
# loki-rules.yml(簡略)
groups:
- name: api-alerts
rules:
- alert: HighErrorLogRate
expr: |
sum(rate({service="api", level="error"}[5m])) > 1
for: 5m
labels:
severity: warning
annotations:
summary: "API error log rate elevated"
description: "Check Loki: {service=\"api\", level=\"error\"}"
Runbook には必ず requestId の調査手順 を書きます。「ユーザーから X-Request-Id を聞く → Grafana Explore で上記クエリ → 該当時間帯の error 行 → 直前の info で下流 URL を特定」。
本番チェックリスト
全サービスで JSON 1行ログと共通スキーマ(service, env, level, requestId)を確認
Express requestId ミドルウェアが最初に実行され、レスポンスヘッダー X-Request-Id を返すことを curl で検証
Pino redact / Winston フィルタで password, authorization, cookie が出力されないことをテスト
下流 HTTP・SQS に requestId が伝播することを統合テストで確認
Promtail が Docker/K8s ログを Loki に送り、Grafana で過去1時間が見えることを確認
requestId 検索クエリを Runbook に記載し、オンコール担当が5分以内に実行できる状態にする
(任意)OpenTelemetry で trace id をログに注入し Tempo/Jaeger とリンク
よくある落とし穴
console.log との混在
開発者が console.log('debug', data) を残すと、Promtail が非 JSON 行として送り、| json パイプラインが部分失敗します。ESLint no-console と CI の grep チェックで防ぎます。
巨大オブジェクトのダンプ
logger.info({ body: req.body }) は個人情報漏えいとログボリューム爆発の原因です。必要フィールドだけをピックアップします。
ラベルのカーディナリティ爆発
requestId や userId を Loki ラベルにすると、メモリ使用量が急増しクラスタが落ちます。ラベルは service, env, level 程度に留め、高カーディナリティは JSON 内に置きます。
タイムゾーン
time は UTC ISO8601 で統一します。Grafana 表示はユーザー TZ に変換されます。JST 文字列を混ぜると相関がずれます。
Kubernetes 環境での補足
EKS/GKE では Promtail を DaemonSet で各ノードに配置するか、Grafana Alloy(旧 Grafana Agent)に移行するのが一般的です。Pod ラベル app.kubernetes.io/name を Promtail の relabel_configs で service ラベルに写すと、Helm chart ごとの LogQL が書きやすくなります。
# relabel 例(抜粋)
- source_labels: ['__meta_kubernetes_pod_label_app_kubernetes_io_name']
target_label: service
- source_labels: ['__meta_kubernetes_namespace']
target_label: namespace
stdout は 1行1 JSON のまま。複数行スタックトレースは Pino / Winston の errors 設定で1 JSON 内の err.stack フィールドに含める方が Loki と相性が良いです。
ログレベル設計
| レベル | 用途 | 例 |
|---|---|---|
debug | 開発・一時調査のみ | SQL バインド値(機密に注意) |
info | 正常系のビジネスイベント | order created, request completed |
warn | リトライ可能・期待外だが継続 | 在庫不足で代替 SKU |
error | 操作失敗・要対応 | DB 接続不可、下流 503 |
fatal | プロセス終了級 | 起動時設定欠落 |
本番のデフォルトは info。障害調査時だけ Pod / 環境変数で LOG_LEVEL=debug に一時変更し、終わったら戻します。
サンプル JSON ログの出力例
リクエスト1本が API → 在庫サービス → レスポンスまで流れたとき、Loki 上では次のような行が同じ requestId で並びます。
{"level":"info","time":"2026-06-21T10:15:32.100Z","service":"api","env":"production","requestId":"7f3a9c2e-4b1d-4e8f-a3c5-9d2e1f0a8b7c","msg":"creating order","userId":42,"path":"/orders"}
{"level":"info","time":"2026-06-21T10:15:32.450Z","service":"api","env":"production","requestId":"7f3a9c2e-4b1d-4e8f-a3c5-9d2e1f0a8b7c","msg":"downstream fetch completed","url":"https://inventory/internal/reserve","statusCode":200,"durationMs":340}
{"level":"info","time":"2026-06-21T10:15:32.451Z","service":"inventory","env":"production","requestId":"7f3a9c2e-4b1d-4e8f-a3c5-9d2e1f0a8b7c","msg":"stock reserved","sku":"WIDGET-01","qty":2}
{"level":"info","time":"2026-06-21T10:15:32.520Z","service":"api","env":"production","requestId":"7f3a9c2e-4b1d-4e8f-a3c5-9d2e1f0a8b7c","msg":"request completed","method":"POST","path":"/orders","statusCode":201,"durationMs":420}
Grafana Explore で requestId を固定すれば、correlation id 分散トレーシングの最小体験——マイクロサービス横断の一本の物語——が実現できます。トレース UI まで昇格する段階では OpenTelemetry による分散トレーシング で span 単位のタイムラインとメトリクス連携を追加してください。
関連記事
この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| APIキャッシュ設計ガイド | APIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務 |
| Web APIのエラーハンドリング設計 | Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け |
| Web APIのべき等性(Idempotency)設計 | Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド |
| API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 | API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 |
| APIのレート制限(Rate Limiting)実装ガイド | APIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング |
| APIレート制限と429対応 | APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装 |
| WebHook設計のベストプラクティス | WebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する |
| ZodでAPI境界のランタイム検証を設計する | ZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026) |
| OpenAPI 3.1仕様のCIバリデーション完全ガイド | OpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions |
| GraphQL N+1 問題の解決 | GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド |
| Circuit Breaker + Retry パターン実装ガイド | Circuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter |
| Sagaパターン完全ガイド | Sagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js) |
まとめ
構造化ログ JSON は、ログを検索可能なデータに変換する基盤です。correlation id(requestId) を Express ミドルウェアと AsyncLocalStorage で全レイヤに渡し、Pino または Winston で一貫した JSON を stdout に出します。収集は Promtail、保存とクエリは Loki、可視化は Grafana——この OSS スタックで、requestId 1つから障害調査を始められる運用が構築できます。
まずは staging 環境で Docker Compose スタックを立ち上げ、意図的に 500 エラーを返すエンドポイントで LogQL の requestId 検索を体験してください。それが通れば、本番への段階導入はログドライバーと Promtail 設定の横展開だけです。