← 中文资源

简体中文版本

结构化日志

结构化日志输出带一致字段的可机读事件——级别、消息、标识、上下文——把 grep 正则换成跨全部服务的索引查询。

阅读英文原文

概念定义

给人读的日志行与给机器读的日志记录是两种工件。结构化日志押注后者:事件携带类型化字段,查询作用于字段而不是字符串碎片。

工作机制

应用经框架以稳定字段名序列化事件;严重级别、消息模板、异常栈都变成字段,trace 与 span id 把日志桥接到链路。摄取在写入时一次解析,索引选定字段并保留整条记录可检索。

使用场景

任何多服务系统都应立即采纳:关联查询、基于日志的指标告警、保留策略全都依赖结构。遗留文本日志可用边车解析器过渡。

常见误区

结构化不等于 JSON——结构在字段,JSON 只是一种编码,logfmt 同样可查询。生产环境保留人格式日志会让字段碎片化并击穿摄取管道——美化只属于开发。字段名跨服务漂移——requestId 对 request_id 对 trace id——会悄悄丢掉关联查询;PII 同乘一条管道,脱敏必须在发出侧而不是摄取侧;调试级全量 dump 会把结构化日志变成结构化洪水。

代码示例

const LEVELS = Object.freeze({ debug: 10, info: 20, warn: 30, error: 40 });

function serializeError(error, seen) {
  if (seen.has(error)) return "[Circular]";
  seen.add(error);
  try {
    const output = { name: error.name, message: error.message };
    if (typeof error.stack === "string") output.stack = error.stack;
    if (error.cause !== undefined) {
      output.cause =
        error.cause instanceof Error
          ? serializeError(error.cause, seen)
          : toSerializable(error.cause, seen);
    }
    return output;
  } finally {
    seen.delete(error);
  }
}

function toSerializable(value, seen = new WeakSet()) {
  if (typeof value === "bigint") return value.toString();
  if (value instanceof Error) return serializeError(value, seen);
  if (value === null || typeof value !== "object") return value;
  if (seen.has(value)) return "[Circular]";
  seen.add(value);
  try {
    if (Array.isArray(value)) {
      return value.map((item) => toSerializable(item, seen));
    }
    if (value instanceof Date) return value.toISOString();
    if (value instanceof Map) {
      return [...value.entries()].map(([key, item]) => [
        toSerializable(key, seen),
        toSerializable(item, seen),
      ]);
    }
    if (value instanceof Set) {
      return [...value].map((item) => toSerializable(item, seen));
    }
    const output = Object.create(null);
    for (const [key, item] of Object.entries(value)) {
      if (key === "__proto__") continue;
      output[key] = toSerializable(item, seen);
    }
    return output;
  } finally {
    seen.delete(value);
  }
}

function safeStringify(value) {
  return JSON.stringify(toSerializable(value));
}

export function createLogger(options = {}) {
  const {
    level = "info",
    base = {},
    write = (line) => process.stdout.write(`${line}\n`),
    now = () => new Date().toISOString(),
  } = options;
  if (!(level in LEVELS)) {
    throw new RangeError(`unknown log level: ${level}`);
  }
  if (base === null || typeof base !== "object" || Array.isArray(base)) {
    throw new TypeError("base must be a plain object");
  }
  if (typeof write !== "function" || typeof now !== "function") {
    throw new TypeError("write and now must be functions");
  }

  const emit = (entryLevel, message, fields = {}) => {
    if (LEVELS[entryLevel] < LEVELS[level]) return;
    if (fields === null || typeof fields !== "object" || Array.isArray(fields)) {
      throw new TypeError("fields must be a plain object");
    }
    let line;
    try {
      const entry = {
        ...base,
        ...fields,
        timestamp: now(),
        level: entryLevel,
        message: String(message),
      };
      line = safeStringify(entry);
    } catch (error) {
      line = JSON.stringify({
        timestamp: now(),
        level: "error",
        message: "failed to serialize log entry",
        error: error instanceof Error ? error.message : String(error),
      });
    }
    write(line);
  };

  return Object.freeze({
    level,
    log: emit,
    debug: (message, fields) => emit("debug", message, fields),
    info: (message, fields) => emit("info", message, fields),
    warn: (message, fields) => emit("warn", message, fields),
    error: (message, fields) => emit("error", message, fields),
    child(bindings) {
      if (
        bindings === null ||
        typeof bindings !== "object" ||
        Array.isArray(bindings)
      ) {
        throw new TypeError("child bindings must be a plain object");
      }
      return createLogger({
        level,
        base: { ...base, ...bindings },
        write,
        now,
      });
    },
  });
}

const logger = createLogger({
  level: "info",
  base: { service: "billing", env: "test" },
});
const requestLogger = logger.child({ requestId: "req-42" });
requestLogger.info("request received", { method: "GET", path: "/invoices" });
requestLogger.warn("slow upstream", { durationMs: 812, attempt: 2 });
requestLogger.error("upstream failed", {
  error: new Error("socket hang up"),
  meta: { retryable: true },
});
logger.debug("filtered out");

const circular = { name: "node" };
circular.self = circular;
requestLogger.info("circular payload", { circular, big: 9007199254740993n });
import json
import logging
import sys
import uuid
from contextvars import ContextVar
from datetime import datetime, timezone

request_id: ContextVar[str] = ContextVar("request_id", default="-")
SECRET_KEYS = {"password", "token", "authorization", "secret"}


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload: dict[str, object] = {
            "ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
            "request_id": request_id.get(),
        }
        context = getattr(record, "context", None)
        if isinstance(context, dict):
            for key, value in context.items():
                safe_key = str(key).lower()
                payload[str(key)] = "***" if safe_key in SECRET_KEYS else value
        if record.exc_info:
            payload["error"] = self.formatException(record.exc_info)
        return json.dumps(payload, ensure_ascii=False, default=str)


def configure_logger() -> logging.Logger:
    logger = logging.getLogger("checkout")
    logger.setLevel(logging.INFO)
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(JsonFormatter())
    logger.handlers[:] = [handler]
    logger.propagate = False
    return logger


def main() -> None:
    logger = configure_logger()
    token = request_id.set("req-" + uuid.uuid4().hex[:8])
    try:
        logger.info(
            "order created",
            extra={"context": {"order_id": 42, "token": "sk_live_example"}},
        )
        try:
            1 / 0
        except ZeroDivisionError:
            logger.exception("pricing failed")
    finally:
        request_id.reset(token)


if __name__ == "__main__":
    main()