構造化ログとは、ログを「1行1件のJSON」のように決まった項目(フィールド)を持つ形式で出力することで、文字列を目で読む代わりに、項目名で検索・集計できるようにする手法です。 たとえば "level":"error" や "http.duration_ms" > 100 で絞り込めるので、エラーの件数や遅いリクエストをすぐに数えられます。

この記事では、どのフィールドを入れるかの一覧表と、OpenTelemetry のログデータモデル・Elastic Common Schema(ECS)での名前の対応表をまとめました。そのうえで、Python(標準 logging の自作JSONフォーマッタ、structlog)と Node.js(pino)のコピペ用コードを小さなHTTPサーバーで動かし、リクエストIDを全ログに自動で付ける方法、パスワード・トークン・メールアドレスのマスキング、jq でエラー件数と遅いリクエストを集計する例まで、実際の出力つきで確認できます。

構造化ログの推奨フィールド早見表

まず入れたいのは「いつ・どのくらい重要か・何が起きたか・どのサービスか・どのリクエストか」の5種類です。 名前は自由に決められますが、後から OpenTelemetry や Elasticsearch に送ることを考え、対応する標準名も並べました。

分類 この記事の名前 例 必須度 OpenTelemetry(ログデータモデル/属性) ECS
いつ timestamp "2026-10-01T14:42:25.477Z" 必須 Timestamp @timestamp
重要度 level "info" / "error" 必須 SeverityText(数値は SeverityNumber) log.level
何が message "request completed" 必須 Body message
どこで service "order-api" 必須 Resource の service.name service.name
どこで env "prod" 必須 Resource の deployment.environment.name service.environment
どこで version "1.4.2" 推奨 Resource の service.version service.version
どこで logger "app" 任意 InstrumentationScope の name log.logger
どのリクエスト request_id "req-001" 必須(Web) 標準の項目なし(Attributes に入れる) http.request.id
どのリクエスト trace_id 32桁の16進 推奨 TraceId trace.id
どのリクエスト span_id 16桁の16進 推奨 SpanId span.id
誰が user_id "u-1001" 任意(扱いに注意) user.id user.id
HTTP http.method "GET" 推奨 http.request.method http.request.method
HTTP http.path "/orders/42" 推奨 url.path url.path
HTTP http.status 200 推奨 http.response.status_code http.response.status_code
HTTP http.duration_ms 0.45 推奨 標準のログ属性なし(メトリクスは http.server.request.duration、秒) event.duration(ナノ秒)
エラー error.type "ValueError" エラー時必須 exception.type error.type
エラー error.message "order_id must be positive" エラー時必須 exception.message error.message
エラー error.stack スタックトレース文字列 エラー時推奨 exception.stacktrace error.stack_trace

出してはいけない情報: パスワード、アクセストークン・APIキー、Authorization ヘッダー、Cookie、カード番号などはキー名で丸ごと伏せ、メールアドレスや氏名などの個人情報は伏せるか一部だけ残すのが基本です(実装は後半)。

設計ルール 理由
1行に1件のJSON(JSON Lines) jq・ログ基盤がそのまま1件ずつ読める
時刻はUTCのISO 8601(Z 付き) タイムゾーンの取り違えをなくす
message は固定の文にし、値は別フィールドへ "order 42 loaded" ではなく "order loaded" + order_id: 42 にすると件数を数えられる
同じ意味には同じ名前と型 status が文字列と数値で混ざると集計で比較できない
単位を名前に入れる(duration_ms) ミリ秒か秒かの取り違えを防ぐ
リクエストIDは仕組みで自動付与 書き忘れがなくなる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「構造化ログ(JSON)設計|必須フィールド一覧とPython・Node実装例」

構造化ログとテキストログの違い

テキストログは人が読みやすく、構造化ログは機械が数えやすい形式です。 同じ出来事を両方で書くと違いがわかります。

# テキストログ
2026-10-01 14:42:25 INFO order-api GET /orders/42 200 0.45ms req=req-001

# 構造化ログ(JSON Lines)
{"timestamp": "2026-10-01T14:42:25.477Z", "level": "info", "message": "request completed", "service": "order-api", "env": "prod", "version": "1.4.2", "logger": "app", "request_id": "req-001", "http": {"method": "GET", "path": "/orders/42", "status": 200, "duration_ms": 0.45}}
比べる点 テキストログ 構造化ログ
人が目で読む 読みやすい 長いが jq で整形できる
「500エラーの件数」を数える 正規表現で項目を切り出す select(.http.status == 500)
項目を追加する 既存の正規表現が合わなくなることがある 既存の集計に影響しない
ログ基盤への取り込み パース設定が必要 ほぼそのまま
開発中の見やすさ 良い 開発時だけ整形表示にする(pino-pretty、structlog の ConsoleRenderer など)

ログレベル(ERROR・WARN・INFO・DEBUG)の使い分け方や、OpenTelemetry によるトレース・メトリクスの導入はそれぞれ専用の記事で扱っているため、この記事ではJSONの項目設計と実装に絞ります。

フィールド設計の考え方

名前の決め方:自前の短い名前か、標準の名前か

最初から ECS や OpenTelemetry の名前に合わせると、ログ基盤へ送るときの変換が少なくなります。 一方、自前の短い名前(level、service)は読みやすく、ログ基盤側の取り込み設定で変換することもできます。

方針 向いている場面 例
自前の短い名前 まずは JSON 化から始めたい、ログ基盤が決まっていない level、service、http.status
ECS に合わせる Elasticsearch / Kibana に送る log.level、service.name、http.response.status_code
OpenTelemetry に合わせる OpenTelemetry Collector 経由で送る SeverityText、service.name、exception.type

どの方針でも、チーム内で一覧表を1つ決めて、全サービスで同じ名前を使うことが一番効きます。

ネスト(入れ子)にするか、フラットにするか

{"http": {"status": 200}} と {"http.status": 200} はどちらも使われます。この記事の実装では、関連する項目をまとめやすいネストを使い、jq では .http.status で参照しています。ECS もネストした JSON とドット区切りの名前のどちらでも同じ項目として扱えます。

レベルの値と重要度の数値

OpenTelemetry のログデータモデルでは、重要度を SeverityNumber で 1〜24 の数値に正規化します。

範囲 意味 よく使う SeverityText
1〜4 TRACE trace
5〜8 DEBUG debug
9〜12 INFO info
13〜16 WARN warn / warning
17〜20 ERROR error
21〜24 FATAL fatal / critical

Python の logging は warning、pino は warn を出すように、ライブラリによって文字列が違う点に注意します。複数言語が混ざる場合は、どちらかに揃えるか、集計時に両方を対象にします。

user_id の扱い

user_id は調査に便利ですが、個人に結び付く情報です。 次の点をチームで決めておくと安心です。

確認すること おすすめ
何を入れるか メールアドレスや氏名ではなく、内部の不透明なID(u-1001 など)
どのログに入れるか 認証済みリクエストのアクセスログなど、必要な場所に限る
保存期間・閲覧権限 ログ基盤側で期間と閲覧できる人を決める
削除依頼への対応 IDからログを検索・削除できるか確認しておく

Python:標準 logging で JSON フォーマッタを自作する

外部ライブラリを入れずに、logging.Formatter を継承して JSON を返すだけで構造化ログにできます。 リクエストIDは contextvars で持ち、マスキングもフォーマッタの中で行います。

前提:Python 3.11 以上(3.11.15 と 3.14.7 で動作確認)、標準ライブラリのみ。ファイル名は app.py。

# Python 3.11+ / 標準ライブラリのみ
import contextvars, json, logging, os, re, sys, time, traceback, uuid
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SERVICE = {"service": "order-api", "env": os.getenv("APP_ENV", "dev"), "version": "1.4.2"}
request_id_var = contextvars.ContextVar("request_id", default=None)

# ---- マスキング ---------------------------------------------------------
SENSITIVE_KEYS = {"password", "passwd", "token", "access_token", "refresh_token",
                  "authorization", "cookie", "secret", "api_key", "card_number"}
EMAIL_RE = re.compile(r"([A-Za-z0-9._%+-])[A-Za-z0-9._%+-]*@([A-Za-z0-9.-]+)")
BEARER_RE = re.compile(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]+")

def mask(value, key=None):
    if key and key.lower() in SENSITIVE_KEYS:
        return "[REDACTED]"
    if isinstance(value, dict):
        return {k: mask(v, k) for k, v in value.items()}
    if isinstance(value, (list, tuple)):
        return [mask(v) for v in value]
    if isinstance(value, str):
        value = BEARER_RE.sub("Bearer [REDACTED]", value)
        return EMAIL_RE.sub(r"\1***@\2", value)
    return value

# ---- JSON フォーマッタ ----------------------------------------------------
RESERVED = set(vars(logging.makeLogRecord({}))) | {"message", "asctime", "taskName"}

class JsonFormatter(logging.Formatter):
    def format(self, record):
        log = {
            "timestamp": datetime.fromtimestamp(record.created, timezone.utc)
                                 .isoformat(timespec="milliseconds").replace("+00:00", "Z"),
            "level": record.levelname.lower(),
            "message": record.getMessage(),
            **SERVICE,
            "logger": record.name,
            "request_id": request_id_var.get(),
        }
        # logger.info("...", extra={...}) で渡した項目を取り込む
        for k, v in record.__dict__.items():
            if k not in RESERVED and not k.startswith("_"):
                log[k] = v
        if record.exc_info:
            etype, evalue, tb = record.exc_info
            log["error"] = {"type": etype.__name__, "message": str(evalue),
                            "stack": "".join(traceback.format_exception(etype, evalue, tb))}
        log = {k: v for k, v in log.items() if v is not None}
        return json.dumps(mask(log), ensure_ascii=False, default=str)

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler], force=True)
logger = logging.getLogger("app")

# ---- 業務処理(request_id を引数で渡していない) ------------------------
def find_order(order_id):
    logger.info("order loaded", extra={"order_id": order_id})
    if order_id == 0:
        raise ValueError("order_id must be positive")
    return {"id": order_id, "status": "shipped"}

# ---- HTTP サーバー -------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):  # 既定のアクセスログ(テキスト)を止める
        pass

    def do_GET(self):
        token = request_id_var.set(self.headers.get("X-Request-ID") or uuid.uuid4().hex)
        start = time.perf_counter()
        status = 200
        try:
            if self.path.startswith("/orders/"):
                body = find_order(int(self.path.rsplit("/", 1)[1]))
            elif self.path == "/slow":
                time.sleep(0.3)
                body = {"ok": True}
            elif self.path == "/login":
                logger.info("login attempt", extra={"user": {"email": "hanako@example.com", "password": "p@ss"},
                                                     "authorization": "Bearer abc.def.ghi"})
                logger.warning("token refresh failed: header=Bearer xyz.123 user=hanako@example.com")
                body = {"ok": True}
            else:
                status, body = 404, {"error": "not found"}
        except Exception:
            logger.exception("request failed")
            status, body = 500, {"error": "internal"}
        data = json.dumps(body).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("X-Request-ID", request_id_var.get())
        self.end_headers()
        self.wfile.write(data)
        level = logging.ERROR if status >= 500 else logging.WARNING if status >= 400 else logging.INFO
        logger.log(level, "request completed", extra={"http": {
            "method": self.command, "path": self.path, "status": status,
            "duration_ms": round((time.perf_counter() - start) * 1000, 2)}})
        request_id_var.reset(token)

if __name__ == "__main__":
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
    logger.info("server started", extra={"port": port})
    ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()

起動とリクエスト:

APP_ENV=prod python app.py 6980 > app.log &
curl -s -H 'X-Request-ID: req-001' localhost:6980/orders/42
curl -s localhost:6980/orders/0
curl -s localhost:6980/login
curl -s localhost:6980/nope

app.log に出力された JSON(実行結果そのまま):

{"timestamp": "2026-10-01T14:42:24.558Z", "level": "info", "message": "server started", "service": "order-api", "env": "prod", "version": "1.4.2", "logger": "app", "port": 6980}
{"timestamp": "2026-10-01T14:42:25.477Z", "level": "info", "message": "order loaded", "service": "order-api", "env": "prod", "version": "1.4.2", "logger": "app", "request_id": "req-001", "order_id": 42}
{"timestamp": "2026-10-01T14:42:25.477Z", "level": "info", "message": "request completed", "service": "order-api", "env": "prod", "version": "1.4.2", "logger": "app", "request_id": "req-001", "http": {"method": "GET", "path": "/orders/42", "status": 200, "duration_ms": 0.45}}
{"timestamp": "2026-10-01T14:42:25.485Z", "level": "info", "message": "order loaded", "service": "order-api", "env": "prod", "version": "1.4.2", "logger": "app", "request_id": "4282ead7985d4db5819f66dc3c0ad2c8", "order_id": 0}
{"timestamp": "2026-10-01T14:42:25.485Z", "level": "error", "message": "request failed", "service": "order-api", "env": "prod", "version": "1.4.2", "logger": "app", "request_id": "4282ead7985d4db5819f66dc3c0ad2c8", "error": {"type": "ValueError", "message": "order_id must be positive", "stack": "Traceback (most recent call last):\n  File \"/tmp/t98app/app.py\", line 75, in do_GET\n    body = find_order(int(self.path.rsplit(\"/\", 1)[1]))\n  File \"/tmp/t98app/app.py\", line 61, in find_order\n    raise ValueError(\"order_id must be positive\")\nValueError: order_id must be positive\n"}}
{"timestamp": "2026-10-01T14:42:25.486Z", "level": "error", "message": "request completed", "service": "order-api", "env": "prod", "version": "1.4.2", "logger": "app", "request_id": "4282ead7985d4db5819f66dc3c0ad2c8", "http": {"method": "GET", "path": "/orders/0", "status": 500, "duration_ms": 1.22}}
{"timestamp": "2026-10-01T14:42:25.494Z", "level": "info", "message": "login attempt", "service": "order-api", "env": "prod", "version": "1.4.2", "logger": "app", "request_id": "1c30828bfef04c2fa73a55f43f26e39d", "user": {"email": "h***@example.com", "password": "[REDACTED]"}, "authorization": "[REDACTED]"}
{"timestamp": "2026-10-01T14:42:25.494Z", "level": "warning", "message": "token refresh failed: header=Bearer [REDACTED] user=h***@example.com", "service": "order-api", "env": "prod", "version": "1.4.2", "logger": "app", "request_id": "1c30828bfef04c2fa73a55f43f26e39d"}
{"timestamp": "2026-10-01T14:42:25.494Z", "level": "info", "message": "request completed", "service": "order-api", "env": "prod", "version": "1.4.2", "logger": "app", "request_id": "1c30828bfef04c2fa73a55f43f26e39d", "http": {"method": "GET", "path": "/login", "status": 200, "duration_ms": 0.4}}
{"timestamp": "2026-10-01T14:42:25.501Z", "level": "warning", "message": "request completed", "service": "order-api", "env": "prod", "version": "1.4.2", "logger": "app", "request_id": "63903f7667df458fbd4bdc6c312df4da", "http": {"method": "GET", "path": "/nope", "status": 404, "duration_ms": 0.09}}

確認できたポイント:

確認したこと 結果
X-Request-ID ヘッダーの値が使われるか req-001 が業務処理のログにも付いた
ヘッダーがないとき UUID が生成され、そのリクエストの全ログに同じ値
find_order() に request_id を渡していないのに付くか 付いた(contextvars から取得)
例外 error.type・error.message・error.stack に分かれて出力
ステータスによるレベル 500 → error、404 → warning、200 → info
マスキング password・authorization は [REDACTED]、メールは h***@example.com、文中の Bearer トークンも伏せられた

実装の要点

  • extra={...} で渡した項目は LogRecord の属性になるので、標準の属性名(RESERVED)以外を取り出して JSON に入れています。extra に message や args など標準の名前を使うと KeyError になるため、order_id のような独自の名前にします。
  • taskName は Python 3.12 で追加された属性です。3.11 でも 3.12 以降でも出力に混ざらないよう RESERVED に足しています。
  • ensure_ascii=False で日本語を \u エスケープせずにそのまま出します。default=str で日時などの JSON にできない値も文字列にします。
  • None の項目は出さないようにして、ログを短くしています。

Python:structlog で書く

structlog は「プロセッサ」を並べて辞書を加工し、最後に JSON にするライブラリで、contextvars 連携が組み込まれています。

前提:pip install structlog(26.1.0 で確認)。

# Python 3.11+ / structlog 26.1
import logging, structlog
from structlog.contextvars import bind_contextvars, clear_contextvars

SENSITIVE = {"password", "token", "authorization", "cookie", "secret"}

def redact(logger, method_name, event_dict):
    for k in list(event_dict):
        if k.lower() in SENSITIVE:
            event_dict[k] = "[REDACTED]"
    return event_dict

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,          # bind した request_id などを合流
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
        structlog.processors.EventRenamer("message"),     # event → message
        redact,
        structlog.processors.ExceptionRenderer(            # 例外を構造化(ローカル変数は出さない)
            structlog.tracebacks.ExceptionDictTransformer(show_locals=False)),
        structlog.processors.JSONRenderer(ensure_ascii=False),
    ],
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
)
log = structlog.get_logger().bind(service="order-api", env="prod", version="1.4.2")

def handle(request_id):
    clear_contextvars()
    bind_contextvars(request_id=request_id)
    log.info("order loaded", order_id=42)
    log.info("login attempt", user_id="u-1001", password="p@ss")
    log.debug("not shown")
    try:
        1 / 0
    except ZeroDivisionError:
        log.exception("calc failed")

handle("req-001")

実行結果:

{"service": "order-api", "env": "prod", "version": "1.4.2", "order_id": 42, "request_id": "req-001", "level": "info", "timestamp": "2026-10-01T14:46:16.817298Z", "message": "order loaded"}
{"service": "order-api", "env": "prod", "version": "1.4.2", "user_id": "u-1001", "password": "[REDACTED]", "request_id": "req-001", "level": "info", "timestamp": "2026-10-01T14:46:16.817398Z", "message": "login attempt"}
{"service": "order-api", "env": "prod", "version": "1.4.2", "request_id": "req-001", "level": "error", "timestamp": "2026-10-01T14:46:16.817437Z", "message": "calc failed", "exception": [{"exc_type": "ZeroDivisionError", "exc_value": "division by zero", "exc_notes": [], "syntax_error": null, "is_cause": false, "frames": [{"filename": "/tmp/t98app/sl.py", "lineno": 35, "name": "handle"}], "is_group": false, "exceptions": []}]}

structlog.processors.dict_tracebacks をそのまま使うと、例外の各フレームに "locals": {"request_id": "'req-001'"} のようなローカル変数が出力されました。 ローカル変数にはパスワードなどが入っていることもあるため、上のように ExceptionDictTransformer(show_locals=False) を指定すると、フレームの位置だけを出せます。

項目 標準 logging 自作 structlog
追加インストール 不要 structlog
値の渡し方 extra={"order_id": 42} log.info("...", order_id=42)
リクエストIDの付与 ContextVar を自分で読む bind_contextvars() + merge_contextvars
既存の logging 利用コード そのまま JSON になる ProcessorFormatter で合流させる設定が必要
開発時の見やすい表示 自作 ConsoleRenderer に差し替えるだけ

Node.js:pino で書く

pino は Node.js で広く使われている JSON ロガーで、mixin でリクエストIDを、redact でマスキングを設定できます。

前提:Node.js 22.22、npm i pino(10.3.1 で確認)。ファイル名は server.mjs。

// Node.js 22 / pino 10
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import http from 'node:http';
import pino from 'pino';

const als = new AsyncLocalStorage();

const logger = pino({
  messageKey: 'message',
  timestamp: pino.stdTimeFunctions.isoTime,         // "time":"2026-..." の ISO 8601
  formatters: {
    level: (label) => ({ level: label }),           // 30 ではなく "info"
    bindings: () => ({ service: 'order-api', env: process.env.APP_ENV ?? 'dev', version: '1.4.2' }),
  },
  mixin: () => ({ request_id: als.getStore()?.requestId }), // 全ログに request_id
  redact: {
    paths: ['password', '*.password', 'token', '*.token', 'authorization',
            'req.headers.authorization', 'req.headers.cookie', '*.email'],
    // email だけは先頭1文字とドメインを残す。それ以外は丸ごと伏せる
    censor: (value, path) =>
      path.at(-1) === 'email' && typeof value === 'string'
        ? value.replace(/^(.).*@/, '$1***@')
        : '[REDACTED]',
  },
  serializers: { err: pino.stdSerializers.err },
});

async function findOrder(id) {
  logger.info({ order_id: id }, 'order loaded');    // request_id を渡していない
  await new Promise((r) => setTimeout(r, 5));       // 非同期処理をまたいでも消えない
  if (id === 0) throw new TypeError('order_id must be positive');
  return { id, status: 'shipped' };
}

const server = http.createServer((req, res) => {
  const requestId = req.headers['x-request-id'] ?? randomUUID();
  als.run({ requestId }, async () => {
    const start = process.hrtime.bigint();
    let status = 200, body;
    try {
      if (req.url.startsWith('/orders/')) {
        body = await findOrder(Number(req.url.split('/').pop()));
      } else if (req.url === '/login') {
        logger.info({ user: { email: 'hanako@example.com', password: 'p@ss' },
                      req: { headers: { authorization: 'Bearer abc.def.ghi' } } }, 'login attempt');
        body = { ok: true };
      } else {
        status = 404; body = { error: 'not found' };
      }
    } catch (err) {
      logger.error({ err }, 'request failed');
      status = 500; body = { error: 'internal' };
    }
    res.writeHead(status, { 'content-type': 'application/json', 'x-request-id': requestId });
    res.end(JSON.stringify(body));
    const duration_ms = Number(process.hrtime.bigint() - start) / 1e6;
    const level = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info';
    logger[level]({ http: { method: req.method, path: req.url, status,
                            duration_ms: Math.round(duration_ms * 100) / 100 } }, 'request completed');
  });
});

server.listen(Number(process.argv[2] ?? 3000), '127.0.0.1', () => logger.info('server started'));
APP_ENV=prod node server.mjs 6981 > node.log &
curl -s -H 'X-Request-ID: req-001' localhost:6981/orders/42
curl -s localhost:6981/orders/0
curl -s localhost:6981/login

実行結果:

{"level":"info","time":"2026-10-01T14:42:51.203Z","service":"order-api","env":"prod","version":"1.4.2","message":"server started"}
{"level":"info","time":"2026-10-01T14:42:52.080Z","service":"order-api","env":"prod","version":"1.4.2","request_id":"req-001","order_id":42,"message":"order loaded"}
{"level":"info","time":"2026-10-01T14:42:52.087Z","service":"order-api","env":"prod","version":"1.4.2","request_id":"req-001","http":{"method":"GET","path":"/orders/42","status":200,"duration_ms":7.17},"message":"request completed"}
{"level":"info","time":"2026-10-01T14:42:52.095Z","service":"order-api","env":"prod","version":"1.4.2","request_id":"e4be6b18-73e6-46a3-88aa-af70492cfc90","order_id":0,"message":"order loaded"}
{"level":"error","time":"2026-10-01T14:42:52.100Z","service":"order-api","env":"prod","version":"1.4.2","request_id":"e4be6b18-73e6-46a3-88aa-af70492cfc90","err":{"type":"TypeError","message":"order_id must be positive","stack":"TypeError: order_id must be positive\n    at findOrder (file:///tmp/t98app/server.mjs:32:23)\n    at async file:///tmp/t98app/server.mjs:43:16"},"message":"request failed"}
{"level":"error","time":"2026-10-01T14:42:52.101Z","service":"order-api","env":"prod","version":"1.4.2","request_id":"e4be6b18-73e6-46a3-88aa-af70492cfc90","http":{"method":"GET","path":"/orders/0","status":500,"duration_ms":6.41},"message":"request completed"}
{"level":"info","time":"2026-10-01T14:42:52.108Z","service":"order-api","env":"prod","version":"1.4.2","request_id":"52105c49-9096-4e58-ab66-b7e36bd86fa0","user":{"email":"h***@example.com","password":"[REDACTED]"},"req":{"headers":{"authorization":"[REDACTED]"}},"message":"login attempt"}
{"level":"info","time":"2026-10-01T14:42:52.109Z","service":"order-api","env":"prod","version":"1.4.2","request_id":"52105c49-9096-4e58-ab66-b7e36bd86fa0","http":{"method":"GET","path":"/login","status":200,"duration_ms":0.6},"message":"request completed"}
pino の設定 効果 既定のままだと
messageKey: 'message' 本文のキーを message に msg
timestamp: pino.stdTimeFunctions.isoTime ISO 8601 の文字列 "time":1790865976969(エポックミリ秒の数値)
formatters.level "level":"info" "level":30(数値)
formatters.bindings サービス名などを全ログに "pid":15703,"hostname":"vm" が入る
mixin 呼び出すたびに項目を足す(request_id) なし
redact 指定パスの値を伏せる 伏せない
serializers.err err を type・message・stack に分解 (err キーは既定でも分解される)

時刻のキーは time のままです。timestamp に揃えたい場合は、timestamp オプションに「先頭がカンマのJSON断片」を返す関数を渡します。

// pino 10.3.1 で確認
import pino from 'pino';
const a = pino({ messageKey: 'message' });
a.info('default');
const b = pino({ messageKey: 'message', base: undefined,
  timestamp: () => `,"timestamp":"${new Date().toISOString()}"`,
  formatters: { level: (label) => ({ level: label }) } });
b.info('custom');
{"level":30,"time":1790865976969,"pid":15703,"hostname":"vm","message":"default"}
{"level":"info","timestamp":"2026-10-01T14:46:16.970Z","message":"custom"}

1行目が pino の既定の出力、2行目が設定後の出力です。

リクエストIDを全ログに付ける仕組み

リクエストIDを関数の引数で引き回さず、「今処理しているリクエストの情報」を保持する仕組みに入れて、ロガーが自動で読み出すのがポイントです。

言語 仕組み 入れる 読む 非同期をまたぐか
Python contextvars.ContextVar var.set(id)(終わりに reset) var.get() スレッド・asyncio タスクごとに独立
Python(structlog) bind_contextvars() bind_contextvars(request_id=id) merge_contextvars プロセッサ 同上
Node.js AsyncLocalStorage als.run({ requestId }, fn) als.getStore() await・setTimeout をまたいでも保持
sequenceDiagram
    participant C as クライアント
    participant M as 受付処理
    participant S as 保持領域(ContextVar / AsyncLocalStorage)
    participant B as 業務処理
    participant L as ロガー
    C->>M: GET /orders/42(X-Request-ID: req-001)
    M->>S: request_id = req-001 を入れる
    M->>B: find_order(42)(IDは渡さない)
    B->>L: logger.info("order loaded")
    L->>S: request_id を読む
    L-->>L: {"request_id":"req-001", ...} を出力
    M->>C: 応答(X-Request-ID: req-001)

同時リクエストで混ざらないか確認する

Node.js のサーバーに、3つのリクエストを同時に送りました(findOrder 内で 5ms 待つので処理が重なります)。

for i in 1 2 3; do curl -s -H "X-Request-ID: par-$i" localhost:6981/orders/$i > /dev/null & done; wait
tail -6 node.log | jq -c '{request_id, message, order_id, path: .http.path}'
{"request_id":"par-1","message":"order loaded","order_id":1,"path":null}
{"request_id":"par-2","message":"order loaded","order_id":2,"path":null}
{"request_id":"par-1","message":"request completed","order_id":null,"path":"/orders/1"}
{"request_id":"par-3","message":"order loaded","order_id":3,"path":null}
{"request_id":"par-2","message":"request completed","order_id":null,"path":"/orders/2"}
{"request_id":"par-3","message":"request completed","order_id":null,"path":"/orders/3"}

ログの順番は入り混じっていますが、それぞれのリクエストIDと注文IDの組は正しく保たれています。 関数の引数ではなく AsyncLocalStorage に入れたことで、await をまたいでも取り違えません。

よくあるつまずき 起きること 対処
グローバル変数に request_id を入れる 同時リクエストで別のIDが付く ContextVar / AsyncLocalStorage を使う
Python で set() した後 reset() しない スレッドを再利用するサーバーで前のIDが残る token = var.set(...) → 最後に var.reset(token)
Python でスレッドプールに処理を渡す 新しいスレッドにはコンテキストが自動で引き継がれない contextvars.copy_context().run(fn) で渡す
受け取った X-Request-ID をそのまま使う 非常に長い文字列などが入る可能性 長さ・文字種を確認し、合わなければ新しく生成
応答ヘッダーに返さない 利用者からの問い合わせとログを結び付けにくい X-Request-ID を応答にも付ける

OpenTelemetry を導入している場合は、trace_id と span_id を同じ方法でログに入れると、トレースとログを相互に行き来できるようになります。

出してはいけない情報とマスキング

マスキングは「キー名で丸ごと伏せる」「値のパターンで一部を伏せる」の2段構えにすると漏れを減らせます。

情報 例 方法 この記事の実装での結果
パスワード password キー名で丸ごと "[REDACTED]"
トークン・APIキー token、api_key、Authorization キー名で丸ごと "[REDACTED]"
文中のトークン "header=Bearer xyz.123" 正規表現 "header=Bearer [REDACTED]"(Python)
Cookie cookie キー名で丸ごと "[REDACTED]"
メールアドレス hanako@example.com 一部を残す "h***@example.com"
カード番号 card_number キー名で丸ごと "[REDACTED]"
氏名・住所・電話番号 name、address 原則ログに入れない -
リクエスト・レスポンス本文 body 原則ログに入れない(必要な項目だけ抜き出す) -

3つの方法の比較:

方法 長所 気を付けること
キー名で伏せる(Python の SENSITIVE_KEYS、pino の redact.paths) 確実で速い キー名が違うと漏れる(pass、pwd など)。一覧を育てる
値のパターンで伏せる(正規表現) 文中に紛れた値も拾える 処理が重くなりやすい。誤検知もある
そもそも渡さない 一番確実 オブジェクトを丸ごとログに渡す書き方を避ける

pino の redact はパスで指定する仕組みのため、message の文中に含まれたトークンは伏せられません。文中に値を埋め込まず、値は別フィールドで渡す書き方にしておくと、キー名でのマスキングが効きます。

マスキングの動作は、次のようなテストで確認しておくと安心です。

# test_mask.py(app.py の mask を使う。Python 3.11 で確認)
from app import mask
assert mask({"password": "x"}) == {"password": "[REDACTED]"}
assert mask({"user": {"token": "abc"}}) == {"user": {"token": "[REDACTED]"}}
assert mask("mail hanako@example.com") == "mail h***@example.com"
assert mask("Authorization: Bearer abc.def") == "Authorization: Bearer [REDACTED]"
print("ok")
python3 test_mask.py
ok

jq でエラー件数・遅いリクエストを集計する

構造化ログにしておくと、jq だけで件数・割合・遅いリクエストを集計できます。 以下は、Python のサーバーに次のリクエストを送った app.log(59行)に対して jq 1.7 で実行した結果です。

for i in $(seq 1 20); do curl -s localhost:6980/orders/$i > /dev/null; done   # 正常 20件
for i in 1 2 3; do curl -s localhost:6980/orders/0 > /dev/null; done          # 500エラー 3件
for i in 1 2; do curl -s localhost:6980/slow > /dev/null; done                 # 300ms 2件
for i in 1 2 3 4; do curl -s localhost:6980/nope > /dev/null; done             # 404 4件
curl -s localhost:6980/login > /dev/null

jq 集計の早見表

やりたいこと コマンド
エラーだけ表示 jq -c 'select(.level=="error")' app.log
エラー件数 jq -s 'map(select(.level=="error")) | length' app.log
レベル別件数 jq -s -c 'group_by(.level) | map({level: .[0].level, count: length})' app.log
ステータス別件数 jq -s -c '[.[] | select(.http)] | group_by(.http.status) | map({status: .[0].http.status, count: length})' app.log
エラーの種類別件数 jq -s -c 'map(select(.error)) | group_by(.error.type) | map({type: .[0].error.type, count: length})' app.log
100ms 超のリクエスト jq -c 'select(.http.duration_ms > 100)' app.log
遅い順に上位3件 jq -s -c '[.[] | select(.http)] | sort_by(-.http.duration_ms) | .[:3]' app.log
1リクエストの全ログ jq -c --arg id "$RID" 'select(.request_id==$id)' app.log
TSV で書き出す jq -r '[.timestamp, .request_id, .message] | @tsv' app.log

実行結果

# レベル別件数
jq -s -c 'group_by(.level) | map({level: .[0].level, count: length})' app.log
[{"level":"error","count":6},{"level":"info","count":48},{"level":"warning","count":5}]
# ステータス別件数
jq -s -c '[.[] | select(.http) ] | group_by(.http.status) | map({status: .[0].http.status, count: length})' app.log
[{"status":200,"count":23},{"status":404,"count":4},{"status":500,"count":3}]
# エラーの種類別件数
jq -s -c 'map(select(.level=="error" and .error)) | group_by(.error.type) | map({type: .[0].error.type, message: .[0].error.message, count: length})' app.log
[{"type":"ValueError","message":"order_id must be positive","count":3}]

error が6件なのは、1回の500エラーで「request failed(例外)」と「request completed(アクセスログ)」の2行が出るためです。 「エラーになったリクエスト数」を数えたいときは http.status >= 500 で、「例外の発生数」を数えたいときは .error があるログで数えると、目的に合った数字になります。

# 100ms を超えたリクエスト
jq -c 'select(.http.duration_ms > 100) | {timestamp, request_id, path: .http.path, ms: .http.duration_ms}' app.log
{"timestamp":"2026-10-01T14:43:23.653Z","request_id":"b798d52b07b8473f936fb19249b16327","path":"/slow","ms":300.33}
{"timestamp":"2026-10-01T14:43:23.961Z","request_id":"59e0ad0837844a838d0c09ff77f66460","path":"/slow","ms":300.26}
# 件数・最大・中央値・95パーセンタイル(簡易計算)
jq -s -c '[.[] | select(.http) | .http.duration_ms] | sort | {count: length, max: .[-1], p50: .[(length*0.5|floor)], p95: .[(length*0.95|floor)]}' app.log
{"count":30,"max":300.33,"p50":0.23,"p95":300.26}
# パス別の件数と平均(/orders/42 などを /orders/:id にまとめる)
jq -s -c '[.[] | select(.http)] | group_by(.http.path | sub("/[0-9]+$"; "/:id")) | map({path: (.[0].http.path | sub("/[0-9]+$"; "/:id")), count: length, avg_ms: ((map(.http.duration_ms) | add / length) * 100 | round / 100)})' app.log
[{"path":"/login","count":1,"avg_ms":0.28},{"path":"/nope","count":4,"avg_ms":0.11},{"path":"/orders/:id","count":23,"avg_ms":0.33},{"path":"/slow","count":2,"avg_ms":300.29}]
# エラーになったリクエストの全ログを request_id でたどる
RID=$(jq -r 'select(.level=="error") | .request_id' app.log | head -1)
jq -c --arg id "$RID" 'select(.request_id==$id) | {timestamp, level, message}' app.log
{"timestamp":"2026-10-01T14:43:23.331Z","level":"info","message":"order loaded"}
{"timestamp":"2026-10-01T14:43:23.331Z","level":"error","message":"request failed"}
{"timestamp":"2026-10-01T14:43:23.332Z","level":"error","message":"request completed"}

request_id で絞り込むと、エラーの直前に「order loaded(order_id: 0)」が出ていたことまで1回でたどれます。 これが、リクエストIDを全ログに付ける一番の効果です。

導入チェックリスト

チェック項目 確認方法
1行1件の JSON になっている jq -c . app.log > /dev/null がエラーにならない
timestamp が UTC の ISO 8601 末尾が Z
level の値が揃っている jq -r .level app.log | sort | uniq -c
service・env・version が全行にある jq -c 'select(.service == null)' app.log が空
リクエスト中のログに request_id がある jq -c 'select(.http and .request_id == null)' app.log が空
例外が error.type / message / stack に分かれている jq -c 'select(.error)' app.log
秘密情報が出ていない grep -E 'p@ss|Bearer [A-Za-z0-9]' app.log が空
duration の単位が名前でわかる duration_ms など

よくある質問

Q. 構造化ログのフィールド名は ECS と OpenTelemetry のどちらに合わせるべきですか? A. 送り先のログ基盤に合わせるのが一番手間が少ないです。 Elasticsearch / Kibana なら ECS、OpenTelemetry Collector 経由なら OpenTelemetry の名前に合わせます。決まっていない場合は、この記事の早見表のような短い名前で始め、一覧表で標準名との対応を管理しておくと後から変換できます。

Q. ログを JSON にすると、開発中に読みにくくなりませんか? A. 開発時だけ整形表示に切り替えるのがおすすめです。pino は pino-pretty、structlog は ConsoleRenderer に差し替えるだけで、色付きの読みやすい表示になります。本番は JSON のまま出力します。

Q. リクエストIDとトレースIDは両方必要ですか? A. OpenTelemetry などで分散トレースを導入しているなら、trace_id がリクエストIDの役割を兼ねられます。 トレースを入れていない場合や、利用者に問い合わせ番号として返したい場合は、request_id を用意すると便利です。両方ある場合は両方をログに入れます。

Q. マスキングはロガー側とログ基盤側のどちらでやるべきですか? A. アプリのロガー側で行うのが基本です。 ログ基盤に届く前に、ファイルや標準出力、転送経路に値が残るためです。ログ基盤側のマスキングは、取りこぼしに備えた2段目として使うと安心です。

まとめ

  • 構造化ログは「1行1件の JSON」で、項目名で検索・集計できるようにする手法
  • 必須は timestamp・level・message・service・env・request_id、HTTP なら method・path・status・duration_ms、エラー時は error.type・message・stack
  • 名前は ECS/OpenTelemetry との対応表を1つ決めて、全サービスで揃える(ECS の event.duration はナノ秒)
  • Python は標準 logging の自作フォーマッタか structlog、Node.js は pino で、どれも数十行で導入できる
  • リクエストIDは contextvars/AsyncLocalStorage に入れて自動付与。同時リクエストでも取り違えない
  • マスキングはキー名で丸ごと+パターンで一部。structlog の例外出力は show_locals=False でローカル変数を出さない
  • jq でレベル別・ステータス別件数、遅いリクエスト、1リクエストの全ログをすぐに集計できる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「構造化ログ(JSON)設計|必須フィールド一覧とPython・Node実装例」

株式会社RJCでは、構造化ログのようなシステムの見える化の知識を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?