構造化ログ(JSON)とCorrelation ID設計|Pino/Winston + Loki/Grafana 実装ガイド

(更新: 2026年6月21日 ) ログ 可観測性 Loki Grafana Node.js バックエンド 分散システム
結論
  • 本番ログは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 > 500statusCode >= 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 / @timestampISO8601 stringイベント発生時刻(UTC 推奨)
levelstringdebug / info / warn / error / fatal
msgstring人間向け短い説明(Pino は msg が慣例)
servicestringapi, worker, billing
envstringproduction, staging, development
requestIdstringcorrelation id(UUID v4 推奨)
traceIdstring(任意)OpenTelemetry trace id
spanIdstring(任意)OpenTelemetry span id
userIdstring | number(任意)認証済みユーザー
durationMsnumber(任意)処理時間
errobject(任意)エラー時 { 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同義。企業標準でこちらを使う例も多い
traceparentW3C Trace Context(OpenTelemetry 標準)

入口(API ゲートウェイまたは最初の Express)で ID がなければ生成し、レスポンスヘッダーにも同じ値を返します。クライアントが X-Request-Id を送ってきた場合はそれを尊重し、サポート問い合わせ時にユーザーが ID を伝えられるようにします。

correlation id 分散トレーシングの最小構成

本格的な 分散トレーシング(Jaeger / Tempo / X-Ray)までは不要な段階でも、次の3点だけで障害調査効率は大きく上がります。

  1. 各サービスで requestId をログフィールドに含める
  2. サービス間 HTTP で X-Request-Id を転送する
  3. Loki / Grafana で {service=~".+"} | json | requestId="..." で横断検索する

OpenTelemetry を導入すると、trace id / span id が自動付与され、ログとトレースビューをリンクできます。詳細は OpenTelemetry による分散トレーシング を参照してください(関連記事)。

1

クライアントまたは ALB が X-Request-Id を付与(無ければ API が UUID を生成)

2

Express ミドルウェアが requestId を AsyncLocalStorage に保存

3

Pino child logger に requestId を bind し、全 log 呼び出しに自動付与

4

axios/fetch で下流サービスへ同じ X-Request-Id を転送

5

ワーカー(SQS 等)はメッセージ属性に requestId を載せて非同期処理でも追跡

6

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 };
ミドルウェア順序

requestIdMiddlewarebody parser より前でも後でも動きますが、ルーターより必ず前に置いてください。httpLoggerMiddlewarerequestIdMiddleware の直後が理想です。認証ミドルウェアで 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 が急増していないかを監視し、デプロイ後の回帰検知に使います。

LogQL と JSON パース

| json は各行を JSON としてパースします。1行に複数 JSONJSON 以外の行 が混ざるとパースエラー行が増えます。構造化ログ JSON を stdout の唯一の形式にし、console.log を禁止する ESLint ルールを検討してください。

Grafana ダッシュボードとアラート

Explore からダッシュボードへ

  1. Grafana → Explore → データソース Loki
  2. 上記 LogQL を入力し、結果を確認
  3. Add to dashboard でパネル化

推奨パネル構成:

パネルクエリ概要
Error rate{service="api", level="error"} の rate
Top slow pathsdurationMs unwrap + quantile_over_time
Recent 5xxstatusCode >= 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 を特定」。

本番チェックリスト

1

全サービスで JSON 1行ログと共通スキーマ(service, env, level, requestId)を確認

2

Express requestId ミドルウェアが最初に実行され、レスポンスヘッダー X-Request-Id を返すことを curl で検証

3

Pino redact / Winston フィルタで password, authorization, cookie が出力されないことをテスト

4

下流 HTTP・SQS に requestId が伝播することを統合テストで確認

5

Promtail が Docker/K8s ログを Loki に送り、Grafana で過去1時間が見えることを確認

6

requestId 検索クエリを Runbook に記載し、オンコール担当が5分以内に実行できる状態にする

7

(任意)OpenTelemetry で trace id をログに注入し Tempo/Jaeger とリンク

よくある落とし穴

console.log との混在

開発者が console.log('debug', data) を残すと、Promtail が非 JSON 行として送り、| json パイプラインが部分失敗します。ESLint no-console と CI の grep チェックで防ぎます。

巨大オブジェクトのダンプ

logger.info({ body: req.body }) は個人情報漏えいとログボリューム爆発の原因です。必要フィールドだけをピックアップします。

ラベルのカーディナリティ爆発

requestIduserId を Loki ラベルにすると、メモリ使用量が急増しクラスタが落ちます。ラベルは service, env, level 程度に留め、高カーディナリティは JSON 内に置きます。

タイムゾーン

timeUTC ISO8601 で統一します。Grafana 表示はユーザー TZ に変換されます。JST 文字列を混ぜると相関がずれます。

Kubernetes 環境での補足

EKS/GKE では Promtail を DaemonSet で各ノードに配置するか、Grafana Alloy(旧 Grafana Agent)に移行するのが一般的です。Pod ラベル app.kubernetes.io/name を Promtail の relabel_configsservice ラベルに写すと、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 設定の横展開だけです。