Split View: 구조화 로깅 설계 — 장애가 났을 때 실제로 도움이 되는 로그 만들기
구조화 로깅 설계 — 장애가 났을 때 실제로 도움이 되는 로그 만들기
- 들어가며 — 장애 한복판에서 로그를 grep할 수 없다면
- 사람이 읽는 로그와 기계가 읽는 로그는 다른 물건이다
- 모든 로그 라인에 반드시 있어야 하는 필드
- 로그 레벨을 실제로 구분하는 단 하나의 기준
- 상관관계 ID를 서비스 경계 너머로 전파하기
- 카디널리티와 비용 — 무엇을 넣지 말아야 하는가
- 로그와 메트릭과 트레이스, 무엇을 언제 보는가
- 마치며 — 로그는 읽는 것이 아니라 거르는 것이다
들어가며 — 장애 한복판에서 로그를 grep할 수 없다면
새벽에 알림을 받고 로그를 엽니다. 서비스 세 개를 지나는 요청인데, 각 서비스의 로그 포맷이 다릅니다. 한쪽은 로컬 시간, 한쪽은 UTC입니다. 요청을 식별할 공통 키가 없어서 타임스탬프가 비슷한 줄들을 눈으로 짝지어 봅니다. 그러다 "user 8812 checkout failed after 4102ms"라는 줄을 찾지만, 이 사용자가 몇 명이나 되는지 세려면 정규식을 새로 짜야 합니다.
이 상황의 원인은 로그가 부족해서가 아닙니다. 오히려 로그는 하루 수억 줄씩 쌓이고 있습니다. 문제는 그 로그가 사람이 한 줄씩 읽는 것을 전제로 설계되었다는 데 있습니다. 사고 대응에서 필요한 것은 읽기가 아니라 필터링과 집계입니다.
이 글은 그 전제를 바꾸는 설계를 다룹니다.
사람이 읽는 로그와 기계가 읽는 로그는 다른 물건이다
로컬 개발 환경의 로그는 사람이 읽습니다. 색이 있고, 문장이고, 순서대로 스크롤하면 이야기가 됩니다. 프로덕션 로그는 기계가 읽습니다. 필터링되고, 집계되고, 조인됩니다. 두 요구사항을 하나의 포맷으로 만족시키려는 시도가 대부분의 로그를 망칩니다.
정답은 같은 로그를 다르게 출력하는 것입니다. 프로덕션은 JSON 한 줄, 로컬은 pretty 포맷으로 렌더링합니다. 그리고 결정적인 규칙 하나가 있습니다.
// 나쁨: 값이 메시지 문자열 안에 녹아 있다
logger.info(`user ${userId} checkout failed after ${ms}ms: ${err.message}`)
// -> 같은 사건을 세려면 정규식이 필요하고, err.message 가 바뀌면 정규식이 깨진다
// 좋음: 메시지는 상수, 변하는 값은 전부 필드
logger.error(
{
event: 'checkout.failed',
user_id: userId,
order_id: orderId,
duration_ms: ms,
payment_provider: 'tosspay',
err: { type: err.name, code: err.code, message: err.message },
},
'checkout failed'
)
메시지 문자열은 상수로 유지합니다. 그래야 같은 사건을 하나의 키로 셀 수 있습니다. 변하는 값이 메시지 안에 들어가는 순간, 그 로그는 문장이 되고 집계 대상에서 탈락합니다.
이 규칙 하나만 지켜도 다음 질문에 즉시 답할 수 있게 됩니다. 이 실패가 몇 분 전부터 시작됐는가, 특정 결제 프로바이더에 몰려 있는가, 특정 테넌트에만 발생하는가. 문장으로 남긴 로그로는 전부 새로 파싱해야 하는 질문들입니다.
event 필드는 별도로 강조할 가치가 있습니다. 점으로 구분한 도메인 이벤트 이름을 안정적인 식별자로 두면, 로그 백엔드에서 그대로 카운터가 됩니다. 메시지 텍스트는 사람을 위한 것이고 event는 기계를 위한 것입니다.
모든 로그 라인에 반드시 있어야 하는 필드
필드가 많을 필요는 없습니다. 없으면 조사가 막히는 필드가 정해져 있습니다.
| 필드 | 예시 값 | 없으면 못 하는 일 |
|---|---|---|
| timestamp | 2026-07-26T04:12:07.481Z | 서비스 간 시간순 정렬 |
| level | error | 심각도 필터링, 알림 연결 |
| service.name | checkout-api | 어느 서비스인지 식별 |
| service.version | 1.42.3 | 배포와 장애의 상관관계 확인 |
| deployment.environment | prod | 스테이징 노이즈 제거 |
| trace_id | 4bf92f3577b34da6a3ce929d0e0e4736 | 트레이스와 로그 상호 이동 |
| request_id | 01J3Q7K8Z2M4F5N6P7R8S9T0V1 | 단일 요청의 전체 로그 수집 |
| tenant_id | acme-corp | 영향 범위 산정 |
| duration_ms | 4102 | 느린 요청 필터링 |
| event | checkout.failed | 사건 단위 집계 |
타임스탬프는 별도로 짚고 갑니다. 반드시 오프셋을 포함한 RFC 3339 형식으로, 가급적 UTC로 남깁니다. 오프셋 없는 로컬 시간은 다음과 같은 일을 만듭니다.
# payment 서비스는 오프셋 없는 KST, checkout 서비스는 UTC 로 남겼다
grep 'payment accepted' payment.log | tail -1
# 2026-07-26T13:02:11.004 payment accepted order_id=A-99183
grep 'checkout completed' checkout.log | tail -1
# 2026-07-26T04:02:11.118Z checkout completed order_id=A-99183
# 같은 사건인데 9시간 차이로 보인다. 두 로그를 한 화면에서 정렬할 방법이 없다.
pino를 예로 들면 기본 설정에서 이렇게 잡습니다.
import pino from 'pino'
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
timestamp: pino.stdTimeFunctions.isoTime, // 오프셋 포함 ISO 8601
formatters: {
level: (label) => ({ level: label }), // 30 대신 "info" 로 남긴다
},
base: {
'service.name': process.env.OTEL_SERVICE_NAME,
'service.version': process.env.SERVICE_VERSION,
'deployment.environment': process.env.APP_ENV,
},
})
base에 넣은 값은 모든 라인에 자동으로 붙습니다. 리소스 수준 필드는 개별 호출부에서 반복하지 않습니다. 반복하는 순간 빠뜨리는 곳이 생깁니다.
로그 레벨을 실제로 구분하는 단 하나의 기준
레벨 정의는 어느 문서에나 있지만 팀마다 실제 사용은 제각각입니다. 판단 기준을 하나로 고정하면 논쟁이 끝납니다.
기준은 이것입니다. ERROR는 우리 시스템이 자기 책임을 다하지 못했다는 뜻입니다. 사용자가 잘못했거나, 예상된 실패거나, 재시도로 복구되었다면 ERROR가 아닙니다.
- ERROR: 우리 쪽 결함으로 요청을 처리하지 못했습니다. 누적되면 페이지로 이어져야 합니다. 예: 의존 서비스 타임아웃 후 재시도 소진, 역직렬화 실패, 커넥션 풀 고갈.
- WARN: 처리는 됐지만 열화되었습니다. 지금 조치할 필요는 없지만 추세는 봐야 합니다. 예: 재시도 후 성공, 폴백 응답 사용, 캐시 미스율 급등, 곧 만료되는 인증서.
- INFO: 상태가 변한 사건입니다. 나중에 재현할 때 필요한 사실만 남깁니다. 예: 주문 생성, 배포 완료, 설정 리로드.
- DEBUG: 개발자를 위한 서사입니다. 프로덕션 기본값은 off이고, 특정 요청이나 특정 테넌트에 한해 동적으로 켤 수 있어야 합니다.
여기서 가장 많이 틀리는 지점이 400번대 응답입니다. 잘못된 요청 본문, 만료된 토큰, 권한 없음은 우리 시스템이 정상 동작한 결과입니다. 이것들을 ERROR로 찍는 순간 ERROR 로그의 대부분이 정상 트래픽으로 채워지고, 진짜 결함이 그 안에 묻힙니다. 클라이언트 오류는 INFO나 WARN으로 남기고, 비율이 급등하는 것은 메트릭으로 감시합니다.
// 에러 핸들러: 책임 소재로 레벨을 가른다
app.use((err, req, res, next) => {
const status = err.status ?? 500
const fields = {
event: 'http.request.failed',
http_status: status,
route: req.route?.path,
err: { type: err.name, code: err.code, message: err.message },
}
if (status >= 500) {
req.log.error(fields, 'request failed') // 우리 책임
} else if (status === 429) {
req.log.warn(fields, 'request throttled') // 열화
} else {
req.log.info(fields, 'request rejected') // 클라이언트 책임
}
res.status(status).json({ error: err.publicMessage ?? 'request failed' })
})
레벨 정책이 정착했는지는 한 가지 지표로 확인합니다. ERROR 로그가 나올 때마다 누군가 확인해 볼 만한가. 아니라면 그 로그는 ERROR가 아닙니다.
상관관계 ID를 서비스 경계 너머로 전파하기
한 서비스 안에서만 유효한 요청 ID는 절반짜리입니다. 값이 나오는 지점은 서비스 경계를 넘을 때입니다.
표준은 W3C Trace Context이고, 헤더는 traceparent입니다. 형식은 네 부분으로 고정되어 있습니다.
curl -sD - -o /dev/null https://checkout.internal/v1/orders \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
-H 'tracestate: acme=t61rcWkgMzE'
# 00 버전
# 4bf92f3577b34da6a3ce929d0e0e4736 트레이스 ID (16바이트, 요청 전체에서 동일)
# 00f067aa0ba902b7 부모 스팬 ID (8바이트, 호출 단계마다 달라짐)
# 01 플래그 (마지막 비트가 1이면 샘플링됨)
마지막 플래그를 눈여겨봐야 합니다. 01이면 이 트레이스는 저장됩니다. 00이면 버려집니다. 로그에 이 값을 함께 남겨두면, 나중에 "트레이스 링크를 눌렀는데 아무것도 없다"는 상황의 원인을 즉시 알 수 있습니다.
애플리케이션에서는 로거가 자동으로 트레이스 컨텍스트를 붙이도록 만드는 것이 핵심입니다. 호출부에서 매번 넘기게 하면 반드시 빠집니다.
import { context, trace } from '@opentelemetry/api'
import { AsyncLocalStorage } from 'node:async_hooks'
const requestStore = new AsyncLocalStorage()
// 요청 진입점에서 한 번만 컨텍스트를 심는다
export function requestContext(req, res, next) {
const requestId = req.headers['x-request-id'] ?? crypto.randomUUID()
res.setHeader('x-request-id', requestId)
requestStore.run({ requestId, tenantId: req.auth?.tenantId }, () => next())
}
// 모든 로그 호출이 통과하는 얇은 래퍼
export function ctx(fields = {}) {
const store = requestStore.getStore() ?? {}
const span = trace.getSpan(context.active())
const sc = span?.spanContext()
return {
...fields,
request_id: store.requestId,
tenant_id: store.tenantId,
trace_id: sc?.traceId,
span_id: sc?.spanId,
trace_sampled: sc ? Boolean(sc.traceFlags & 1) : undefined,
}
}
logger.error(ctx({ event: 'checkout.failed', order_id: orderId }), 'checkout failed')
전파에서 실제로 깨지는 지점은 세 곳입니다. 첫째, 자체 HTTP 클라이언트를 쓰는 코드 경로 — 자동 계측이 감싸지 못하면 헤더가 붙지 않습니다. 둘째, 메시지 큐 — 헤더에 직접 주입하지 않으면 프로듀서와 컨슈머의 트레이스가 끊깁니다. 셋째, 배치 작업과 크론 — 진입점에서 새 트레이스를 시작하지 않으면 로그에 trace_id가 아예 비어 있습니다.
카디널리티와 비용 — 무엇을 넣지 말아야 하는가
로그는 무료가 아닙니다. 계산부터 해 봅니다.
# 한 줄 평균 1.2KB, 초당 4,000줄
python3 -c "print(f'{1.2*1024*4000*86400/1e9:.1f} GB/day')"
# 424.7 GB/day
# 30일 보관, 인덱싱 포함 단가를 GB당 2.5 통화단위로 가정하면
python3 -c "print(f'{424.7*30*2.5:,.0f} per month')"
# 31,853 per month
여기서 줄일 대상은 라인 수가 아니라 라인당 크기와 인덱싱 대상 필드입니다. 다음은 로그에 넣지 않습니다.
- 개인정보: 이메일 전체, 전화번호, 주소, 생년월일, 주민등록번호. 필요한 경우 해시나 내부 ID로 대체합니다.
- 시크릿: Authorization 헤더, 쿠키, API 키, 카드번호, 리프레시 토큰. 이 값들은 한 번 로그에 들어가면 백업과 인덱스와 콜드 스토리지에 동시에 복제됩니다.
- 대용량 페이로드: 요청과 응답 본문 전체, 스택 트레이스의 반복 출력, 직렬화된 객체 덤프. 본문은 크기와 해시만 남기고, 필요하면 별도 저장소에 짧은 TTL로 둡니다.
- 무의미한 고유값: 로그 백엔드가 인덱싱하는 필드에 UUID를 무제한으로 넣으면 인덱스가 데이터보다 커집니다. 인덱싱할 필드와 저장만 할 필드를 구분합니다.
마스킹은 애플리케이션 안에서 처리하는 것이 원칙입니다. 수집 파이프라인에서 지우는 방식은 이미 프로세스 밖으로 나간 뒤라 유출 경로가 늘어납니다.
export const logger = pino({
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'req.headers["x-api-key"]',
'req.body.password',
'req.body.card_number',
'*.email',
'*.phone',
'user.ssn',
],
censor: '[REDACTED]',
},
})
샘플링은 볼륨을 줄이는 마지막 수단이지만 방법이 중요합니다. 흔한 조언인 "INFO 로그의 10%만 남긴다"를 라인 단위 무작위로 구현하면 최악의 결과가 나옵니다. 한 요청의 로그가 조각조각 남아서 어떤 요청도 완전한 이야기를 갖지 못합니다.
샘플링은 라인이 아니라 요청 단위로 결정합니다. trace_id를 해시해서 판단하면 같은 요청의 모든 로그가 함께 남거나 함께 버려집니다.
const KEEP_RATIO = 0.05
// trace_id 로 결정적 판정 — 한 요청의 로그는 전부 남거나 전부 빠진다
function keepLowSeverity(traceId) {
if (!traceId) return true
const bucket = parseInt(traceId.slice(-4), 16) / 0xffff
return bucket < KEEP_RATIO
}
export function emit(level, fields, msg) {
const enriched = ctx(fields)
const alwaysKeep = level === 'error' || level === 'warn' || enriched.trace_sampled
if (!alwaysKeep && !keepLowSeverity(enriched.trace_id)) return
logger[level](enriched, msg)
}
ERROR와 WARN은 절대 샘플링하지 않습니다. 그리고 트레이스가 샘플링된 요청의 로그는 함께 남깁니다. 그래야 트레이스에서 로그로 넘어갔을 때 빈 화면을 보지 않습니다.
로그와 메트릭과 트레이스, 무엇을 언제 보는가
세 신호는 대체재가 아니라 조사 순서입니다. 셋을 다 켜 놓고도 조사가 느린 팀은 보통 순서가 없습니다.
- 메트릭이 먼저입니다. 언제부터 무엇이 잘못되었는지, 영향 범위가 얼마나 되는지에 답합니다. 값이 싸고 항상 켜져 있고 카디널리티가 낮기 때문에 알림의 근거가 될 수 있는 유일한 신호입니다.
- 트레이스가 다음입니다. 느려진 요청 하나를 열어서 어느 서비스, 어느 구간에서 시간이 갔는지에 답합니다. 메트릭이 알려준 시간대와 라우트로 범위를 좁혀서 진입합니다.
- 로그가 마지막입니다. 그 스팬 안에서 정확히 무슨 일이 있었는지, 어떤 값이 들어와서 어떤 분기를 탔는지에 답합니다. trace_id로 필터링해 들어가면 읽어야 할 줄이 수십 줄로 줄어듭니다.
이 순서가 작동하려면 신호 사이의 연결 고리가 필요합니다. 메트릭에서 트레이스로 가는 다리는 익젬플러입니다. 히스토그램 관측에 trace_id를 붙여 두면, 그래프의 튀는 점을 클릭해서 바로 그 트레이스를 열 수 있습니다.
# 익젬플러가 붙어 있으면 이 그래프의 점에서 바로 트레이스로 이동한다
histogram_quantile(
0.99,
sum by (le, route) (rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)
트레이스에서 로그로 가는 다리는 앞에서 만든 trace_id 필드입니다. 로그에서 메트릭으로 가는 다리는 event 필드입니다. 세 다리가 다 놓여 있으면 조사 시간이 수십 분 단위에서 수 분 단위로 내려갑니다. 하나라도 빠지면 그 지점에서 다시 눈으로 짝짓기가 시작됩니다.
마치며 — 로그는 읽는 것이 아니라 거르는 것이다
기억할 것은 한 문장입니다. 프로덕션 로그의 목적은 읽히는 것이 아니라 걸러지는 것입니다.
그 목적에서 모든 규칙이 따라 나옵니다. 메시지는 상수로 두고 값은 필드로 뺍니다. 모든 라인에 trace_id와 service.name과 오프셋이 포함된 타임스탬프를 넣습니다. ERROR는 우리 책임일 때만 씁니다. 민감한 값은 프로세스를 떠나기 전에 지웁니다. 샘플링은 요청 단위로 합니다.
당장 하나만 고친다면 메시지 문자열에서 변수를 빼내는 것부터 시작하십시오. 이 변경 하나가 정규식으로만 답할 수 있던 질문들을 즉시 집계 가능한 질문으로 바꿉니다.
더 파고들 자료입니다.
Designing Structured Logging — Logs That Actually Help During an Incident
- Introduction — When You Cannot Grep Your Logs Mid-Incident
- Logs for Humans and Logs for Machines Are Different Things
- The Fields Every Log Line Must Carry
- The Single Criterion That Actually Separates Log Levels
- Propagating the Correlation ID Past Service Boundaries
- Cardinality and Cost — What You Must Not Put In
- Logs, Metrics, and Traces — What to Look At and When
- Closing — Logs Are Not for Reading, They Are for Filtering
Introduction — When You Cannot Grep Your Logs Mid-Incident
An alert wakes you at dawn and you open the logs. The request passes through three services, and each service has a different log format. One is local time, another is UTC. There is no common key to identify the request, so you pair up lines with similar timestamps by eye. Eventually you find a line reading "user 8812 checkout failed after 4102ms", but counting how many such users there are means writing a fresh regular expression.
The cause of this situation is not a shortage of logs. On the contrary, hundreds of millions of lines pile up every day. The problem is that those logs were designed on the assumption that a human would read them one line at a time. What incident response needs is not reading but filtering and aggregation.
This post is about the design that changes that assumption.
Logs for Humans and Logs for Machines Are Different Things
Logs in a local development environment are read by humans. They have colour, they are sentences, and scrolling through them in order tells a story. Production logs are read by machines. They get filtered, aggregated, and joined. Trying to satisfy both requirements with one format is what ruins most logs.
The answer is to emit the same log differently. Production gets one line of JSON, local gets a pretty-printed rendering. And there is one decisive rule.
// Bad: the values are melted into the message string
logger.info(`user ${userId} checkout failed after ${ms}ms: ${err.message}`)
// -> counting the same event needs a regex, and the regex breaks when err.message changes
// Good: the message is a constant, every varying value is a field
logger.error(
{
event: 'checkout.failed',
user_id: userId,
order_id: orderId,
duration_ms: ms,
payment_provider: 'tosspay',
err: { type: err.name, code: err.code, message: err.message },
},
'checkout failed'
)
Keep the message string constant. That is what lets you count the same event under a single key. The moment a varying value moves inside the message, that log becomes a sentence and drops out of anything aggregatable.
Following this one rule alone lets you answer the following questions immediately. When did this failure start, is it concentrated on a particular payment provider, does it only happen to one tenant. With logs left as sentences, every one of those questions requires fresh parsing.
The event field deserves separate emphasis. Put a dot-separated domain event name there as a stable identifier and it becomes a counter directly in the log backend. The message text is for humans and event is for machines.
The Fields Every Log Line Must Carry
You do not need many fields. The fields whose absence blocks an investigation are a fixed set.
| Field | Example value | What you cannot do without it |
|---|---|---|
| timestamp | 2026-07-26T04:12:07.481Z | Chronological ordering across services |
| level | error | Severity filtering, wiring up alerts |
| service.name | checkout-api | Identifying which service it is |
| service.version | 1.42.3 | Correlating deployments with incidents |
| deployment.environment | prod | Removing staging noise |
| trace_id | 4bf92f3577b34da6a3ce929d0e0e4736 | Jumping between traces and logs |
| request_id | 01J3Q7K8Z2M4F5N6P7R8S9T0V1 | Collecting all logs of a single request |
| tenant_id | acme-corp | Estimating blast radius |
| duration_ms | 4102 | Filtering slow requests |
| event | checkout.failed | Aggregating by event |
Timestamps deserve a separate note. Always use RFC 3339 format including the offset, and preferably UTC. Local time without an offset produces situations like this.
# The payment service logs KST with no offset, the checkout service logs UTC
grep 'payment accepted' payment.log | tail -1
# 2026-07-26T13:02:11.004 payment accepted order_id=A-99183
grep 'checkout completed' checkout.log | tail -1
# 2026-07-26T04:02:11.118Z checkout completed order_id=A-99183
# Same event, but they look nine hours apart. There is no way to sort both logs on one screen.
With pino, for example, you set this up in the base configuration.
import pino from 'pino'
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
timestamp: pino.stdTimeFunctions.isoTime, // ISO 8601 including the offset
formatters: {
level: (label) => ({ level: label }), // emit "info" instead of 30
},
base: {
'service.name': process.env.OTEL_SERVICE_NAME,
'service.version': process.env.SERVICE_VERSION,
'deployment.environment': process.env.APP_ENV,
},
})
Values placed in base are attached to every line automatically. Do not repeat resource-level fields at individual call sites. The moment you repeat them, somewhere will forget one.
The Single Criterion That Actually Separates Log Levels
Level definitions appear in every document, but actual usage differs from team to team. Fix on one criterion for the judgement and the arguments end.
The criterion is this. ERROR means our system failed to do its job. If the user made a mistake, if the failure was expected, or if a retry recovered it, it is not an ERROR.
- ERROR: a defect on our side prevented the request from being handled. Accumulation should lead to a page. Examples: retries exhausted after a dependency timeout, deserialization failure, connection pool exhaustion.
- WARN: it was handled but in a degraded way. No action is needed now, but the trend must be watched. Examples: success after retry, fallback response served, cache miss rate spiking, a certificate about to expire.
- INFO: an event that changed state. Keep only the facts you will need to reconstruct things later. Examples: order created, deployment finished, configuration reloaded.
- DEBUG: narrative for developers. The production default is off, and it must be possible to turn on dynamically for a specific request or a specific tenant.
The point people get wrong most often here is 4xx responses. A malformed request body, an expired token, a lack of permission — these are the results of our system working correctly. The moment you stamp them as ERROR, most of your ERROR logs fill up with normal traffic and real defects get buried inside. Log client errors as INFO or WARN, and watch for spikes in their ratio with metrics.
// Error handler: split the level by where the responsibility lies
app.use((err, req, res, next) => {
const status = err.status ?? 500
const fields = {
event: 'http.request.failed',
http_status: status,
route: req.route?.path,
err: { type: err.name, code: err.code, message: err.message },
}
if (status >= 500) {
req.log.error(fields, 'request failed') // our responsibility
} else if (status === 429) {
req.log.warn(fields, 'request throttled') // degradation
} else {
req.log.info(fields, 'request rejected') // client responsibility
}
res.status(status).json({ error: err.publicMessage ?? 'request failed' })
})
Check whether the level policy has taken hold with one indicator. Is every ERROR log something worth someone looking at. If not, that log is not an ERROR.
Propagating the Correlation ID Past Service Boundaries
A request ID that is only valid inside one service is half a solution. The point where it starts paying off is when it crosses a service boundary.
The standard is W3C Trace Context, and the header is traceparent. The format is fixed at four parts.
curl -sD - -o /dev/null https://checkout.internal/v1/orders \
-H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01' \
-H 'tracestate: acme=t61rcWkgMzE'
# 00 version
# 4bf92f3577b34da6a3ce929d0e0e4736 trace ID (16 bytes, identical across the whole request)
# 00f067aa0ba902b7 parent span ID (8 bytes, differs at each call hop)
# 01 flags (a trailing bit of 1 means sampled)
Watch that last flag carefully. 01 means this trace will be stored. 00 means it will be dropped. Log this value alongside everything else and you can immediately explain the situation where "I clicked the trace link and there was nothing there".
In the application, the key is to make the logger attach the trace context automatically. If call sites have to pass it every time, it will definitely get missed.
import { context, trace } from '@opentelemetry/api'
import { AsyncLocalStorage } from 'node:async_hooks'
const requestStore = new AsyncLocalStorage()
// Plant the context exactly once at the request entry point
export function requestContext(req, res, next) {
const requestId = req.headers['x-request-id'] ?? crypto.randomUUID()
res.setHeader('x-request-id', requestId)
requestStore.run({ requestId, tenantId: req.auth?.tenantId }, () => next())
}
// A thin wrapper every log call passes through
export function ctx(fields = {}) {
const store = requestStore.getStore() ?? {}
const span = trace.getSpan(context.active())
const sc = span?.spanContext()
return {
...fields,
request_id: store.requestId,
tenant_id: store.tenantId,
trace_id: sc?.traceId,
span_id: sc?.spanId,
trace_sampled: sc ? Boolean(sc.traceFlags & 1) : undefined,
}
}
logger.error(ctx({ event: 'checkout.failed', order_id: orderId }), 'checkout failed')
There are three places where propagation actually breaks. First, code paths using a hand-rolled HTTP client — if auto-instrumentation cannot wrap it, the header is never attached. Second, message queues — unless you inject into the headers yourself, the producer and consumer traces are severed. Third, batch jobs and cron — unless you start a new trace at the entry point, trace_id is simply empty in the logs.
Cardinality and Cost — What You Must Not Put In
Logs are not free. Start with the arithmetic.
# 1.2KB per line on average, 4,000 lines per second
python3 -c "print(f'{1.2*1024*4000*86400/1e9:.1f} GB/day')"
# 424.7 GB/day
# Assuming 30 day retention at a unit price of 2.5 currency units per GB including indexing
python3 -c "print(f'{424.7*30*2.5:,.0f} per month')"
# 31,853 per month
What you should reduce here is not the number of lines but the size per line and the set of indexed fields. The following do not belong in logs.
- Personal data: full email addresses, phone numbers, home addresses, dates of birth, national ID numbers. Substitute a hash or an internal ID where you need one.
- Secrets: Authorization headers, cookies, API keys, card numbers, refresh tokens. Once these land in a log they are replicated simultaneously into backups, indexes, and cold storage.
- Large payloads: full request and response bodies, repeated stack trace dumps, serialized object dumps. Keep only the size and a hash of the body, and if you truly need it, park it in separate storage with a short TTL.
- Meaningless unique values: put unbounded UUIDs into a field the log backend indexes and the index grows larger than the data. Distinguish fields you index from fields you merely store.
Redaction should happen inside the application as a matter of principle. Stripping it in the collection pipeline happens after the value has already left the process, which multiplies the leak paths.
export const logger = pino({
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'req.headers["x-api-key"]',
'req.body.password',
'req.body.card_number',
'*.email',
'*.phone',
'user.ssn',
],
censor: '[REDACTED]',
},
})
Sampling is the last resort for reducing volume, but the method matters. Implement the common advice of "keep only 10% of INFO logs" as a per-line random draw and you get the worst possible outcome. One request leaves fragments of logs behind and no request has a complete story.
Decide sampling per request, not per line. Hash the trace_id to make the call and all the logs of one request are kept together or dropped together.
const KEEP_RATIO = 0.05
// Deterministic decision from the trace_id — one request keeps all its logs or none
function keepLowSeverity(traceId) {
if (!traceId) return true
const bucket = parseInt(traceId.slice(-4), 16) / 0xffff
return bucket < KEEP_RATIO
}
export function emit(level, fields, msg) {
const enriched = ctx(fields)
const alwaysKeep = level === 'error' || level === 'warn' || enriched.trace_sampled
if (!alwaysKeep && !keepLowSeverity(enriched.trace_id)) return
logger[level](enriched, msg)
}
Never sample ERROR and WARN. And keep the logs of a request whose trace was sampled. That is what stops you staring at an empty screen after jumping from a trace to the logs.
Logs, Metrics, and Traces — What to Look At and When
The three signals are not substitutes for each other, they are an investigation order. A team that has all three switched on and still investigates slowly usually has no order.
- Metrics come first. They answer when something started going wrong and how big the blast radius is. They are cheap, always on, and low cardinality, which makes them the only signal that can justify an alert.
- Traces come next. They answer, for one slow request, which service and which segment consumed the time. Enter with the scope narrowed by the time range and route the metrics pointed at.
- Logs come last. They answer exactly what happened inside that span, which values arrived and which branch they took. Filter by trace_id on the way in and the number of lines to read drops to a few dozen.
For this order to work you need links between the signals. The bridge from metrics to traces is exemplars. Attach a trace_id to histogram observations and you can click a spike on the graph and open exactly that trace.
# With exemplars attached, you jump straight from a point on this graph to the trace
histogram_quantile(
0.99,
sum by (le, route) (rate(http_request_duration_seconds_bucket{job="checkout-api"}[5m]))
)
The bridge from traces to logs is the trace_id field we built earlier. The bridge from logs to metrics is the event field. With all three bridges in place, investigation time drops from tens of minutes to a few minutes. Miss even one and eye-based pairing starts again at that point.
Closing — Logs Are Not for Reading, They Are for Filtering
There is one sentence to remember. The purpose of a production log is not to be read but to be filtered.
Every rule follows from that purpose. Keep the message constant and pull the values out into fields. Put trace_id, service.name, and a timestamp with an offset on every line. Use ERROR only when it is our responsibility. Wipe sensitive values before they leave the process. Sample per request.
If you fix exactly one thing today, start by pulling the variables out of your message strings. That single change turns questions that only a regular expression could answer into questions you can aggregate immediately.
Further reading.