본문으로 건너뛰기

구조화 로거 — Cloud Logging 자동 파싱

소스: apps/web/lib/logging/logger.ts

왜 JSON 구조화인가

Cloud Run 은 stdout/stderr 의 JSON 라인 을 자동으로 파싱해 Cloud Logging 의 jsonPayload 필드로 정리. severity 필드가 있으면 로그 레벨로 인식되어 Grafana·Logs Explorer 에서 severity=ERROR 쿼리·Alerting 가능. 평문 console.error("...")textPayload 로만 남아 필터가 어렵다.

→ Day 0 부터 JSON 구조화 적용. SDK 불필요.

API

import { logInfo, logWarn, logError } from "@/lib/logging/logger";

logInfo("event.name", { ...context });
logWarn("event.name", { ...context });
logError("event.name", { ...context });

출력 포맷 (예):

{
"severity": "ERROR",
"event": "ai.refund.failed",
"timestamp": "2026-04-20T09:15:33.241Z",
"context": {
"reason": "upstream_5xx",
"tenantId": "abc123",
"weight": 3
}
}

규약

event 이름

  • 짧고 검색 가능한 키 (예: "ai.refund", "portal-token.stale", "embedding.stale")
  • dot 또는 hyphen 으로 구분. 공백·CamelCase 금지
  • 같은 계열은 공통 prefix 로 묶기 (ai.*, portal-token.*)

context 필드

  • PII 포함 금지 — 사용자 입력·본문·이메일·전화 등 원문 직접 금지. ID·카운트·태그만
  • Firestore 경로·리소스 ID 는 OK (로그 필터 용)
  • 자유 형식 Record<string, unknown> — 필드명 통일은 이벤트별 문서화
  • 중첩 객체·배열 직렬화 OK (JSON.stringify 기준)

Severity 선택

레벨언제예시
INFO필수가 아닌 관찰"user.session.created" · "dashboard.cache.hit"
WARNING비치명적 이상, 재시도 가능"embedding.stale" · "ai.retryable-5xx"
ERROR사용자 영향 있는 실패"ai.refund.failed" · "plan.enforce.violation"

마이그레이션 (점진적)

현행

} catch (err) {
console.error("[ai-client] 전략 보고서 생성 실패:", err);
}

개선 (신규 코드부터 적용)

import { logError } from "@/lib/logging/logger";

} catch (err) {
logError("ai.strategy-report.failed", {
tenantId,
caseId,
errorName: err instanceof Error ? err.name : "Unknown",
errorMessage: err instanceof Error ? err.message : String(err),
});
}

기존 console.error 전수 교체는 별도 PR 로 분리. Phase 0 동안은 신규 코드에만 강제.

Anti-Pattern

  • console.error("foo", err) — 평문, Cloud Logging 파싱 실패
  • logError("foo", { body: req.body }) — PII 위험 (원문 포함)
  • logError("AI failed", { ... }) — 공백·대문자 이벤트명. "ai.failed"
  • ❌ 에러 객체 자체를 context.err = err 로 넣기 — JSON 직렬화가 Error 인스턴스를 빈 {} 로 만듦. err.message / err.name 을 풀어 전달

관련