Skip to content

Split View: 로그를 검색 가능하게, 그리고 파산하지 않게 — 구조화, 매핑 폭발, 보존, 그리고 진짜 비용

✨ Learn with Quiz
|

로그를 검색 가능하게, 그리고 파산하지 않게 — 구조화, 매핑 폭발, 보존, 그리고 진짜 비용

들어가며 — 로그 비용이 컴퓨트 비용을 넘어선 날

분기 인프라 비용을 정리하다가 로그 저장과 인덱싱이 애플리케이션 컴퓨트보다 비싸다는 사실을 발견하는 일은 드물지 않습니다. 그리고 그 로그의 대부분은 아무도 검색한 적이 없습니다.

원인은 대개 압축률이나 스토리지 단가가 아닙니다. 처음에 "일단 다 넣자"로 시작해서, 필드가 늘어나고, 인덱스가 커지고, 보존 기간을 줄이자니 감사 요건이 걸리고, 그러다 클러스터가 불안정해지는 경로입니다.

이 글은 그 경로를 되짚어 각 지점에서 무엇을 결정했어야 하는지를 다룹니다. OpenSearch 3.5와 OpenTelemetry Collector v0.157.0 기준으로 확인했습니다. Elasticsearch 계열도 개념은 같지만 ISM에 해당하는 기능 이름이 ILM으로 다릅니다.

로그가 답하는 질문, 답하지 못하는 질문

먼저 경계를 긋습니다. 로그는 개별 이벤트의 기록이므로 "왜"에 답합니다. 어떤 값이 들어와서 어떤 분기를 탔고 어떤 예외가 났는지는 로그만 알고 있습니다.

반대로 로그가 답하기에 비싼 질문들이 있습니다.

질문적합한 신호로그로 하면
이 요청이 왜 실패했는가로그정확히 이 용도입니다
지금 에러율이 몇 퍼센트인가메트릭매번 전체 스캔, 비용이 질의 횟수에 비례
어제와 비교해 어떤가메트릭보존 기간이 짧아 비교 대상이 없는 경우가 많음
요청이 어느 서비스에서 느려졌는가트레이스서비스별 로그를 시간으로 짝짓는 추측
30일 전 특정 주문의 처리 내역로그이것도 로그의 용도입니다

로그를 줄이는 첫 번째 방법은 압축이 아니라 다른 신호로 옮길 수 있는 질문을 옮기는 것입니다.

구조화 로그 — 무엇을 필드로 만들 것인가

구조화 로그는 문자열 대신 키와 값을 씁니다. 이유는 파싱 비용이 아니라 검색 가능성입니다.

# 나쁨 — 검색하려면 정규식이 필요하고, 포맷이 바뀌면 그 정규식이 깨진다
log.info(f"order {order_id} for tenant {tenant} failed after {ms}ms: {err}")

# 좋음 — 필드로 검색하고 집계한다
log.info(
    "order.failed",
    extra={
        "order.id": order_id,
        "tenant.id": tenant,
        "duration_ms": ms,
        "error.type": type(err).__name__,
        "error.message": str(err)[:500],
        "http.route": route,
    },
)

이벤트 이름을 메시지 자리에 넣은 것이 핵심입니다. order.failed 처럼 값이 유한한 이름을 쓰면 그것으로 그룹핑과 집계가 됩니다. 메시지에 ID가 섞이면 같은 사건이 전부 다른 문자열이 되어 집계가 불가능합니다.

필드 이름은 처음부터 규약을 정합니다. OpenTelemetry 시맨틱 규약을 그대로 쓰면 로그와 트레이스의 속성 이름이 일치해서 상관 조회가 쉬워집니다.

{
  "@timestamp": "2026-08-02T04:11:52.418Z",
  "severity_text": "ERROR",
  "severity_number": 17,
  "body": "order.failed",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "service.name": "checkout-api",
  "service.version": "2.7.1",
  "deployment.environment.name": "prod",
  "http.route": "/v1/orders/:id",
  "http.response.status_code": 502,
  "error.type": "UpstreamTimeout",
  "order.id": "A-99183",
  "tenant.id": "t-8871",
  "duration_ms": 1421
}

trace_id 가 들어 있는 것이 중요합니다. 이것 하나로 트레이스에서 로그로, 로그에서 트레이스로 건너갈 수 있습니다. 자동 계측이 로그 상관을 지원하면 코드 수정 없이 붙습니다.

# Python 자동 계측에서 로그에 trace_id 와 span_id 를 주입한다
export OTEL_PYTHON_LOG_CORRELATION=true
export OTEL_PYTHON_LOG_LEVEL=info

필드 설계의 원칙은 셋입니다.

  1. 이름은 고정, 값은 가변입니다. 필드 이름 자체에 ID나 날짜를 넣지 않습니다. 이 규칙 하나가 매핑 폭발의 90%를 막습니다.
  2. 한 필드는 한 타입입니다. duration 이 어떤 로그에서는 숫자이고 어떤 로그에서는 "1.4s" 문자열이면, 먼저 색인된 타입으로 매핑이 고정되고 나머지는 색인 실패로 버려집니다.
  3. 큰 덩어리는 필드가 아니라 본문입니다. 스택 트레이스, 요청 본문, 응답 페이로드는 검색 가능한 필드로 만들지 말고 인덱싱하지 않는 본문 필드에 넣습니다.

매핑 폭발 — 인덱스가 죽는 실제 경로

동적 매핑은 처음 보는 필드를 자동으로 등록합니다. 편리하지만, 필드 이름이 데이터에서 나오는 순간 위험해집니다.

{
  "message": "cache stats",
  "cache": {
    "user_8871_hits": 12,
    "user_8871_misses": 3,
    "user_9902_hits": 7
  }
}

사용자마다 새 필드가 생깁니다. 사용자가 10만 명이면 필드가 20만 개입니다. 매핑은 클러스터 상태에 들어가고, 클러스터 상태는 모든 노드가 공유하며 변경 시마다 전파됩니다. 결과는 다음과 같은 순서로 나타납니다.

  1. 색인 지연이 늘어납니다. 새 필드마다 매핑 갱신이 필요하고 이것은 마스터 노드를 거칩니다
  2. 마스터 노드의 CPU와 힙이 오릅니다
  3. 클러스터 상태 전파가 느려지고 노드가 이탈하기 시작합니다
  4. 샤드 재할당이 시작되면서 색인과 검색이 함께 느려집니다
  5. 필드 상한에 걸려 색인이 거부됩니다

가장 나쁜 점은 3번까지 진행되면 되돌리기가 어렵다는 것입니다. 매핑은 삭제할 수 없고, 인덱스를 다시 만드는 수밖에 없습니다.

방어선을 세 겹으로 칩니다.

PUT _index_template/logs-app
{
  "index_patterns": ["logs-app-*"],
  "data_stream": {},
  "priority": 200,
  "template": {
    "settings": {
      "index.number_of_shards": 3,
      "index.number_of_replicas": 1,
      "index.refresh_interval": "30s",
      "index.codec": "zstd_no_dict",
      "index.mapping.total_fields.limit": 1500,
      "index.mapping.depth.limit": 8,
      "index.mapping.nested_fields.limit": 20
    },
    "mappings": {
      "dynamic": "strict",
      "properties": {
        "@timestamp":        { "type": "date" },
        "severity_text":     { "type": "keyword" },
        "body":              { "type": "keyword" },
        "trace_id":          { "type": "keyword" },
        "span_id":           { "type": "keyword" },
        "service.name":      { "type": "keyword" },
        "service.version":   { "type": "keyword" },
        "http.route":        { "type": "keyword" },
        "http.response.status_code": { "type": "short" },
        "error.type":        { "type": "keyword" },
        "error.message":     { "type": "text", "index": true, "norms": false },
        "duration_ms":       { "type": "integer" },
        "tenant.id":         { "type": "keyword" },
        "order.id":          { "type": "keyword", "doc_values": true, "index": true },
        "stack_trace":       { "type": "text", "index": false },
        "attributes":        { "type": "flat_object" }
      }
    }
  }
}

세 겹이 각각 무엇을 막는지 봅니다.

첫째, dynamic: strict. 정의하지 않은 필드가 오면 문서를 거부합니다. 강하지만 정직합니다. 로그가 조용히 사라지는 것보다 색인 실패가 눈에 보이는 편이 낫습니다. 거부가 부담스러우면 false 로 두어 저장은 하되 색인하지 않게 할 수 있습니다.

둘째, flat_object 필드 하나. 예측할 수 없는 키-값이 반드시 들어오는 자리를 만들어 둡니다. flat_object는 객체 전체를 하나의 필드로 취급하므로 하위 키가 매핑에 등록되지 않습니다. 점 표기법으로 조회는 되지만 하위 키별 인덱스가 없어 검색은 느립니다. 그 트레이드오프가 정확히 우리가 원하는 것입니다.

셋째, 필드 수 상한. 사고가 나도 클러스터 전체가 아니라 그 인덱스만 멈춥니다.

방어선막는 것대가
dynamic strict예상 못 한 필드의 등록새 필드를 추가하려면 템플릿 변경 필요
flat_object임의 키의 매핑 등록하위 키 검색이 느리고 집계 제약
total_fields.limit사고의 폭발 범위한도 초과 시 색인 실패
index: false큰 텍스트의 색인 비용그 필드로 검색 불가, 조회만 가능
norms: false텍스트 필드의 스코어링 메타데이터관련도 순위가 덜 정교해짐

현재 상태 진단은 이렇게 합니다.

# 인덱스별 필드 수 — 수천 개가 나오면 이미 문제다
curl -s 'https://opensearch:9200/logs-app-000042/_mapping?pretty' \
  | jq '[paths(type=="object" and has("type")) | length] | length'

# 클러스터 상태 크기와 매핑 갱신 대기열
curl -s 'https://opensearch:9200/_cluster/stats?pretty' \
  | jq '.indices.mappings'

curl -s 'https://opensearch:9200/_cluster/health?pretty' \
  | jq '{status, number_of_pending_tasks, task_max_waiting_in_queue_millis}'

# 어떤 인덱스가 디스크를 쓰는가
curl -s 'https://opensearch:9200/_cat/indices/logs-*?v&s=store.size:desc&h=index,docs.count,store.size,pri,rep' \
  | head -20

number_of_pending_tasks 가 꾸준히 0이 아니면 매핑 갱신이 밀리고 있다는 신호입니다. 이 값에 알림을 걸어 두면 매핑 폭발을 3단계 전에 잡을 수 있습니다.

인덱스 수명주기 — 롤오버와 계층 이동

로그 인덱스는 하나로 두지 않고 시간이나 크기로 쪼갭니다. 이유는 단순합니다. 삭제가 인덱스 단위로는 즉시이고 문서 단위로는 비싸기 때문입니다. 하루치 로그를 지우려고 delete-by-query를 돌리면 세그먼트 재작성이 일어나 클러스터가 흔들립니다.

PUT _plugins/_ism/policies/logs-app-lifecycle
{
  "policy": {
    "description": "애플리케이션 로그 30일 보존",
    "default_state": "hot",
    "ism_template": [
      {
        "index_patterns": ["logs-app-*"],
        "priority": 200
      }
    ],
    "states": [
      {
        "name": "hot",
        "actions": [
          {
            "rollover": {
              "min_primary_shard_size": "30gb",
              "min_index_age": "1d"
            }
          }
        ],
        "transitions": [
          { "state_name": "warm", "conditions": { "min_index_age": "3d" } }
        ]
      },
      {
        "name": "warm",
        "actions": [
          { "replica_count": { "number_of_replicas": 0 } },
          { "force_merge": { "max_num_segments": 1 } }
        ],
        "transitions": [
          { "state_name": "cold", "conditions": { "min_index_age": "10d" } }
        ]
      },
      {
        "name": "cold",
        "actions": [
          { "read_only": {} }
        ],
        "transitions": [
          { "state_name": "delete", "conditions": { "min_index_age": "30d" } }
        ]
      },
      {
        "name": "delete",
        "actions": [{ "delete": {} }]
      }
    ]
  }
}

min_primary_shard_size 로 롤오버하는 것이 시간 기준보다 안전합니다. 트래픽이 튀는 날 하루치 인덱스가 300GB가 되면 샤드 하나가 100GB를 넘어 검색이 급격히 느려집니다. 크기 기준이면 그날은 인덱스가 여러 개로 나뉩니다.

warm 단계에서 복제본을 0으로 내리는 것은 비용과 내구성의 교환입니다. 오래된 로그가 노드 장애로 유실돼도 감당할 수 있는지를 먼저 정하고 결정합니다. 감사 대상 로그라면 이 단계를 넣지 않고 별도 인덱스와 별도 보존 정책으로 분리합니다.

정책이 실제로 도는지 확인하는 것을 잊지 않습니다. ISM은 조용히 실패합니다.

# 정책이 붙은 인덱스와 현재 상태
curl -s 'https://opensearch:9200/_plugins/_ism/explain/logs-app-*?pretty' \
  | jq 'to_entries[] | select(.value.index != null)
        | {index: .value.index, state: .value."policy_id", step: .value.step.name, failed: .value.failed}'

# 실패한 관리 인덱스만
curl -s 'https://opensearch:9200/_plugins/_ism/explain/logs-app-*?pretty' \
  | jq '[to_entries[] | select(.value.failed == true) | .key]'

샘플링 — 로그를 버리는 원칙 있는 방법

전량 보존이 비용에 맞지 않으면 버려야 하고, 문제는 무엇을 버리느냐입니다. 원칙은 하나입니다. 가치가 낮은 것이 아니라 중복이 높은 것을 버립니다.

가장 효과가 큰 것부터 나열합니다.

  1. 헬스체크와 프로브 로그 — 전체 로그의 20~40%를 차지하는 경우가 흔합니다. 정보량은 거의 0입니다
  2. 성공 경로의 DEBUG와 INFO — 오류가 없었던 요청의 상세 로그
  3. 반복되는 동일 이벤트 — 같은 error.type이 초당 수천 건이면 상위 N건과 카운트만 남깁니다
  4. 정적 자산 요청 — 이미지, JS, CSS 접근 로그

컬렉터에서 자릅니다. 앱을 고치지 않고 정책만 바꿀 수 있는 위치이기 때문입니다.

# otel-collector.yaml — 로그 파이프라인
processors:
  memory_limiter:
    check_interval: 1s
    limit_percentage: 80
    spike_limit_percentage: 20

  # 1) 헬스체크는 통째로 버린다
  filter/drop_health:
    error_mode: ignore
    logs:
      log_record:
        - 'attributes["http.route"] == "/healthz"'
        - 'attributes["http.route"] == "/readyz"'
        - 'attributes["http.route"] == "/metrics"'

  # 2) 정상 경로의 DEBUG 는 10% 만 남긴다. 오류는 건드리지 않는다
  probabilistic_sampler/debug:
    sampling_percentage: 10
    attribute_source: record
    from_attribute: trace_id

  # 3) 큰 필드는 잘라 낸다
  transform/trim:
    error_mode: ignore
    log_statements:
      - context: log
        statements:
          - truncate_all(attributes, 4096)
          - delete_key(attributes, "http.request.body")
          - set(attributes["error.message"], Substring(attributes["error.message"], 0, 500))
            where attributes["error.message"] != nil

  batch:
    timeout: 5s
    send_batch_size: 8192

exporters:
  opensearch:
    http:
      endpoint: https://opensearch.observability.svc:9200
    logs_index: logs-app
    sending_queue:
      enabled: true
      queue_size: 5000
    retry_on_failure:
      enabled: true
      max_elapsed_time: 300s

service:
  pipelines:
    # 오류와 경고는 무조건 전량 보존
    logs/critical:
      receivers: [otlp]
      processors: [memory_limiter, transform/trim, batch]
      exporters: [opensearch]
    # 정상 경로는 샘플링
    logs/routine:
      receivers: [otlp]
      processors: [memory_limiter, filter/drop_health, probabilistic_sampler/debug, transform/trim, batch]
      exporters: [opensearch]

from_attribute: trace_id 가 중요합니다. 같은 트레이스의 로그가 함께 남거나 함께 버려지므로, 남은 로그가 조각나지 않습니다. 무작위로 각 로그 레코드를 판정하면 한 요청의 로그 중 세 줄만 남아 조사에 쓸 수 없게 됩니다.

라우팅으로 신호별 파이프라인을 나누는 것도 방법입니다. 심각도에 따라 다른 인덱스와 다른 보존 기간으로 보내면, ERROR는 90일 남기고 INFO는 7일만 남기는 정책이 가능해집니다.

계층대상보존인덱싱상대 비용
뜨거움ERROR, WARN, 감사 이벤트30~90일전체 필드기준
미지근함INFO 중 비즈니스 이벤트14~30일핵심 필드만0.4배
차가움성공 경로 접근 로그3~7일최소0.15배
보관규제 대응용 원본1~7년없음, 객체 스토리지0.02배

맨 아래 줄이 자주 잊힙니다. 감사 요건 때문에 보존 기간을 못 줄인다면, 그 로그를 검색 클러스터가 아니라 객체 스토리지에 원본으로 두고 필요할 때만 꺼내는 구조가 훨씬 쌉니다. 검색 가능성과 보존은 다른 요구사항입니다.

로그로 메트릭을 흉내 낼 때 드는 진짜 비용

"로그가 다 있으니 거기서 에러율을 계산하면 되지 않나"는 자연스러운 생각이고, 소규모에서는 실제로 동작합니다. 규모가 커지면 비용 구조가 뒤집힙니다.

숫자로 봅니다.

# 규모 가정
rps            = 5_000          # 초당 요청
log_bytes      = 800            # 로그 1건의 색인 후 크기 (바이트)
seconds_of_day = 86_400

daily_gb = rps * log_bytes * seconds_of_day / 1024**3
print(f"하루 색인량: {daily_gb:.1f} GB")
# 하루 색인량: 321.8 GB

# 같은 정보를 메트릭으로 표현하면
routes, status_classes, pods = 120, 5, 40
series = routes * status_classes * pods
samples_per_day = series * (seconds_of_day / 15)      # 15초 스크레이프
metric_bytes = samples_per_day * 2                    # 압축 후 샘플당 약 2 바이트
print(f"시계열 {series:,}개, 하루 {metric_bytes/1024**3:.3f} GB")
# 시계열 24,000개, 하루 0.257 GB

1000배 이상 차이가 납니다. 그리고 이것은 저장 비용만 본 것입니다. 실제 격차는 질의에서 더 벌어집니다.

항목로그로 에러율 계산메트릭으로 에러율 계산
하루 저장량수백 GB수백 MB
30초마다 새로고침하는 대시보드매번 전체 구간 스캔미리 계산된 시계열 조회
30일 비교 질의30일치 문서를 훑음, 수십 초수백 ms
보존 기간비용 때문에 7~14일이 한계1년 이상 현실적
알림 평가1분마다 무거운 집계 질의저렴한 벡터 연산
스파이크 시 동작로그가 늘어난 만큼 질의도 느려짐시계열 수는 그대로

마지막 줄이 가장 위험합니다. 장애가 나면 로그가 폭증하고, 바로 그 순간 로그 기반 알림과 대시보드가 가장 느려집니다. 필요할 때 작동하지 않는 관측 시스템입니다.

옳은 방향은 로그를 줄이는 것이 아니라 집계는 메트릭으로 옮기고 로그는 조사용으로 남기는 것입니다. 컬렉터가 이 변환을 대신해 줍니다.

# 로그에서 카운트 메트릭을 만들어 Prometheus 로 보낸다
connectors:
  count:
    logs:
      log.error.count:
        description: 심각도별 로그 발생 수
        conditions:
          - 'severity_number >= 17'
        attributes:
          - key: service.name
          - key: error.type
          - key: http.route

service:
  pipelines:
    logs/in:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [opensearch, count]
    metrics/from_logs:
      receivers: [count]
      processors: [batch]
      exporters: [prometheusremotewrite]

attributes 목록에 무엇을 넣는지가 전부입니다. 여기 들어간 것이 그대로 메트릭 레이블이 되므로, order.idtenant.id 를 넣으면 시계열이 폭발합니다. 로그에서는 문제없던 고카디널리티 필드가 메트릭으로 넘어가는 순간 문제가 됩니다.

실패 모드와 진단

증상원인확인대응
로그가 일부만 도착필드 타입 충돌로 색인 거부색인 실패 응답, 데드레터필드 타입 고정, 앱에서 타입 통일
색인 지연이 계속 증가매핑 갱신 대기열 적체클러스터 대기 작업 수dynamic strict, flat_object 도입
특정 시간대 검색이 급격히 느려짐샤드 하나가 지나치게 큼인덱스별 샤드 크기크기 기준 롤오버
오래된 인덱스가 안 지워짐ISM 정책 실패 후 방치ISM explain 의 failed 필드실패 인덱스 재적용, 알림 추가
디스크가 갑자기 참force_merge 중 임시 공간병합 작업 상태warm 이동 전 여유 공간 확보
검색은 되는데 집계가 안 됨flat_object 하위 키매핑 확인집계가 필요한 키는 명시 필드로 승격
장애 때 로그가 유실컬렉터 큐 포화컬렉터 드롭 카운터큐 크기와 백프레셔 정책 조정

마지막 줄은 특히 신경 씁니다. 로그 파이프라인의 백프레셔가 앱까지 전달되면, 관측 시스템이 서비스를 죽이는 상황이 됩니다. 컬렉터의 큐가 차면 드롭하도록 두고, 드롭 카운터에 알림을 겁니다. 로그 유실은 나쁘지만 서비스 중단보다는 낫습니다.

exporters:
  opensearch:
    sending_queue:
      enabled: true
      queue_size: 5000
      # 큐가 차면 새 데이터를 버린다. 앱으로 백프레셔를 되돌리지 않는다
      block_on_overflow: false

마치며 — 로그의 비용은 양이 아니라 필드 설계가 정한다

같은 트래픽에서 로그 비용이 열 배 차이 나는 두 조직의 차이는 압축 알고리즘이 아닙니다. 하나는 필드 이름이 고정되어 있고 집계 질문을 메트릭으로 옮겨 두었으며, 다른 하나는 동적 매핑을 켜 둔 채 대시보드가 로그를 훑고 있습니다.

지금 할 수 있는 점검은 셋입니다. 첫째, 가장 큰 로그 인덱스의 필드 수를 세어 봅니다. 네 자리면 이번 분기의 과제가 정해진 것입니다. 둘째, 지난 30일 동안 실제로 검색된 인덱스 목록을 뽑아 봅니다. 검색된 적 없는 인덱스가 저장량의 절반을 차지하는 경우가 많습니다. 셋째, 로그를 스캔하는 대시보드 패널을 찾아 메트릭으로 옮길 수 있는지 확인합니다.

더 파고들 자료입니다.

Making Logs Searchable, and Not Going Broke Doing It — Structuring, Mapping Explosions, Retention, and Real Cost

Introduction — The Day Log Costs Overtook Compute Costs

It's not rare to be tallying up quarterly infrastructure costs and discover that log storage and indexing cost more than application compute. And most of those logs have never been searched by anyone.

The cause usually isn't compression ratios or storage unit prices. It's a path that starts with "let's just put everything in for now," where fields multiply, indexes grow, cutting retention runs into audit requirements, and eventually the cluster becomes unstable.

This post retraces that path and covers what should have been decided at each point. Verified against OpenSearch 3.5 and OpenTelemetry Collector v0.157.0. The Elasticsearch family shares the same concepts, but the feature corresponding to ISM is named ILM there instead.

Questions Logs Can Answer, and Questions They Can't

Let's draw the boundary first. Since a log is a record of an individual event, it answers "why." Only the log knows what value came in, which branch it took, and what exception occurred.

Conversely, there are questions that are expensive for logs to answer.

QuestionThe right signalIf done with logs
Why did this request failLogsExactly what this is for
What's the error rate right nowMetricsA full scan every time, cost scales with query count
How does it compare to yesterdayMetricsShort retention often leaves nothing to compare against
Where in the service chain did a request slow downTracesGuesswork, pairing per-service logs by timestamp
The processing history of a specific order from 30 days agoLogsThis is also what logs are for

The first way to cut down logs isn't compression — it's moving to another signal any question that can be moved.

Structured Logging — What Should Become a Field

Structured logging uses keys and values instead of a string. The reason is searchability, not parsing cost.

# Bad — searching requires a regex, and if the format changes, that regex breaks
log.info(f"order {order_id} for tenant {tenant} failed after {ms}ms: {err}")

# Good — search and aggregate by field
log.info(
    "order.failed",
    extra={
        "order.id": order_id,
        "tenant.id": tenant,
        "duration_ms": ms,
        "error.type": type(err).__name__,
        "error.message": str(err)[:500],
        "http.route": route,
    },
)

Putting the event name where the message goes is the key move. Use a name with a finite set of values, like order.failed, and that alone gives you grouping and aggregation. Mix an ID into the message, and the same kind of event turns into a different string every time, which makes aggregation impossible.

Set the field-naming convention from the start. Use the OpenTelemetry semantic conventions as-is, and log and trace attribute names line up, which makes correlated lookups easy.

{
  "@timestamp": "2026-08-02T04:11:52.418Z",
  "severity_text": "ERROR",
  "severity_number": 17,
  "body": "order.failed",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "service.name": "checkout-api",
  "service.version": "2.7.1",
  "deployment.environment.name": "prod",
  "http.route": "/v1/orders/:id",
  "http.response.status_code": 502,
  "error.type": "UpstreamTimeout",
  "order.id": "A-99183",
  "tenant.id": "t-8871",
  "duration_ms": 1421
}

Having trace_id in there matters. With just this one field, you can jump from trace to log and from log to trace. If auto-instrumentation supports log correlation, it attaches without any code changes.

# Python auto-instrumentation injects trace_id and span_id into logs
export OTEL_PYTHON_LOG_CORRELATION=true
export OTEL_PYTHON_LOG_LEVEL=info

There are three principles for field design.

  1. The name stays fixed, the value varies. Never put an ID or a date into the field name itself. This one rule alone blocks 90% of mapping explosions.
  2. One field, one type. If duration is a number in one log and the string "1.4s" in another, the mapping locks to whichever type got indexed first, and the rest get dropped as indexing failures.
  3. A large blob is body, not a field. Stack traces, request bodies, response payloads — don't turn these into searchable fields; put them in a body field that isn't indexed.

Mapping Explosion — The Real Path to a Dead Index

Dynamic mapping auto-registers fields it hasn't seen before. It's convenient, but the moment a field name comes from the data itself, it becomes dangerous.

{
  "message": "cache stats",
  "cache": {
    "user_8871_hits": 12,
    "user_8871_misses": 3,
    "user_9902_hits": 7
  }
}

A new field is created per user. With 100,000 users, that's 200,000 fields. Mapping goes into cluster state, cluster state is shared by every node, and it propagates on every change. The result unfolds in this order.

  1. Indexing latency climbs. Every new field needs a mapping update, which goes through the master node
  2. The master node's CPU and heap rise
  3. Cluster-state propagation slows down and nodes start dropping out
  4. Shard reallocation kicks in, and indexing and search both slow down together
  5. Indexing gets rejected once it hits the field cap

The worst part is that once it reaches step 3, it's hard to reverse. A mapping can't be deleted — the only option is to recreate the index.

Build three layers of defense.

PUT _index_template/logs-app
{
  "index_patterns": ["logs-app-*"],
  "data_stream": {},
  "priority": 200,
  "template": {
    "settings": {
      "index.number_of_shards": 3,
      "index.number_of_replicas": 1,
      "index.refresh_interval": "30s",
      "index.codec": "zstd_no_dict",
      "index.mapping.total_fields.limit": 1500,
      "index.mapping.depth.limit": 8,
      "index.mapping.nested_fields.limit": 20
    },
    "mappings": {
      "dynamic": "strict",
      "properties": {
        "@timestamp":        { "type": "date" },
        "severity_text":     { "type": "keyword" },
        "body":              { "type": "keyword" },
        "trace_id":          { "type": "keyword" },
        "span_id":           { "type": "keyword" },
        "service.name":      { "type": "keyword" },
        "service.version":   { "type": "keyword" },
        "http.route":        { "type": "keyword" },
        "http.response.status_code": { "type": "short" },
        "error.type":        { "type": "keyword" },
        "error.message":     { "type": "text", "index": true, "norms": false },
        "duration_ms":       { "type": "integer" },
        "tenant.id":         { "type": "keyword" },
        "order.id":          { "type": "keyword", "doc_values": true, "index": true },
        "stack_trace":       { "type": "text", "index": false },
        "attributes":        { "type": "flat_object" }
      }
    }
  }
}

Let's look at what each of the three layers blocks.

First, dynamic: strict. Rejects a document if an undefined field shows up. Harsh, but honest. A visible indexing failure beats logs silently vanishing. If outright rejection feels too costly, you can set it to false to store without indexing instead.

Second, one flat_object field. Set aside a spot where unpredictable key-value pairs are bound to land. flat_object treats the whole object as a single field, so its sub-keys never get registered in the mapping. You can still look things up with dot notation, but there's no per-sub-key index, so search is slow. That trade-off is exactly what we want.

Third, a cap on the field count. Even when an incident happens, only that index stalls, not the whole cluster.

LayerWhat it blocksCost
dynamic strictRegistration of an unexpected fieldAdding a new field requires a template change
flat_objectMapping registration of arbitrary keysSub-key search is slow, aggregation is constrained
total_fields.limitThe blast radius of an incidentIndexing fails once the cap is exceeded
index: falseThe indexing cost of large textThat field can't be searched, only retrieved
norms: falseScoring metadata for a text fieldRelevance ranking gets less precise

Diagnose the current state like this.

# Field count per index — thousands means you already have a problem
curl -s 'https://opensearch:9200/logs-app-000042/_mapping?pretty' \
  | jq '[paths(type=="object" and has("type")) | length] | length'

# Cluster state size and the mapping-update queue
curl -s 'https://opensearch:9200/_cluster/stats?pretty' \
  | jq '.indices.mappings'

curl -s 'https://opensearch:9200/_cluster/health?pretty' \
  | jq '{status, number_of_pending_tasks, task_max_waiting_in_queue_millis}'

# Which indices are writing to disk
curl -s 'https://opensearch:9200/_cat/indices/logs-*?v&s=store.size:desc&h=index,docs.count,store.size,pri,rep' \
  | head -20

If number_of_pending_tasks is consistently nonzero, that's a sign mapping updates are backing up. Alert on this value, and you can catch a mapping explosion before step 3.

Index Lifecycle — Rollover and Tier Migration

Don't keep the log index as one — split it by time or size. The reason is simple: deletion is instant at the index level, but expensive at the document level. Run a delete-by-query to wipe a day's worth of logs, and it triggers segment rewrites that shake the cluster.

PUT _plugins/_ism/policies/logs-app-lifecycle
{
  "policy": {
    "description": "30-day retention for application logs",
    "default_state": "hot",
    "ism_template": [
      {
        "index_patterns": ["logs-app-*"],
        "priority": 200
      }
    ],
    "states": [
      {
        "name": "hot",
        "actions": [
          {
            "rollover": {
              "min_primary_shard_size": "30gb",
              "min_index_age": "1d"
            }
          }
        ],
        "transitions": [
          { "state_name": "warm", "conditions": { "min_index_age": "3d" } }
        ]
      },
      {
        "name": "warm",
        "actions": [
          { "replica_count": { "number_of_replicas": 0 } },
          { "force_merge": { "max_num_segments": 1 } }
        ],
        "transitions": [
          { "state_name": "cold", "conditions": { "min_index_age": "10d" } }
        ]
      },
      {
        "name": "cold",
        "actions": [
          { "read_only": {} }
        ],
        "transitions": [
          { "state_name": "delete", "conditions": { "min_index_age": "30d" } }
        ]
      },
      {
        "name": "delete",
        "actions": [{ "delete": {} }]
      }
    ]
  }
}

Rolling over by min_primary_shard_size is safer than doing it by time. On a day traffic spikes and that day's index hits 300GB, a single shard blows past 100GB and search slows down sharply. With a size-based trigger, that day's index just splits into several instead.

Dropping replicas to 0 in the warm state trades cost for durability. Decide first whether you can tolerate losing older logs to a node failure, then make the call. For logs subject to audit, skip this step and separate them into their own index with their own retention policy.

Don't forget to confirm the policy is actually running. ISM fails silently.

# Indices with a policy attached, and their current state
curl -s 'https://opensearch:9200/_plugins/_ism/explain/logs-app-*?pretty' \
  | jq 'to_entries[] | select(.value.index != null)
        | {index: .value.index, state: .value."policy_id", step: .value.step.name, failed: .value.failed}'

# Only the managed indices that have failed
curl -s 'https://opensearch:9200/_plugins/_ism/explain/logs-app-*?pretty' \
  | jq '[to_entries[] | select(.value.failed == true) | .key]'

Sampling — A Principled Way to Throw Logs Away

If retaining everything doesn't fit the budget, you have to throw some away, and the question is what. There's one principle: throw away what's high in redundancy, not what's low in value.

Listed from highest impact down.

  1. Health check and probe logs — commonly 20–40% of all logs. Their information content is close to zero
  2. DEBUG and INFO on the success path — detailed logs for requests that had no error
  3. The same event repeating — if the same error.type shows up thousands of times a second, keep only the top N plus a count
  4. Requests for static assets — access logs for images, JS, CSS

Cut it in the collector. That's the spot where you can change policy without touching the app.

# otel-collector.yaml — logs pipeline
processors:
  memory_limiter:
    check_interval: 1s
    limit_percentage: 80
    spike_limit_percentage: 20

  # 1) Drop health checks entirely
  filter/drop_health:
    error_mode: ignore
    logs:
      log_record:
        - 'attributes["http.route"] == "/healthz"'
        - 'attributes["http.route"] == "/readyz"'
        - 'attributes["http.route"] == "/metrics"'

  # 2) Keep only 10% of DEBUG on the normal path. Leave errors untouched
  probabilistic_sampler/debug:
    sampling_percentage: 10
    attribute_source: record
    from_attribute: trace_id

  # 3) Truncate large fields
  transform/trim:
    error_mode: ignore
    log_statements:
      - context: log
        statements:
          - truncate_all(attributes, 4096)
          - delete_key(attributes, "http.request.body")
          - set(attributes["error.message"], Substring(attributes["error.message"], 0, 500))
            where attributes["error.message"] != nil

  batch:
    timeout: 5s
    send_batch_size: 8192

exporters:
  opensearch:
    http:
      endpoint: https://opensearch.observability.svc:9200
    logs_index: logs-app
    sending_queue:
      enabled: true
      queue_size: 5000
    retry_on_failure:
      enabled: true
      max_elapsed_time: 300s

service:
  pipelines:
    # Errors and warnings are always retained in full
    logs/critical:
      receivers: [otlp]
      processors: [memory_limiter, transform/trim, batch]
      exporters: [opensearch]
    # The normal path gets sampled
    logs/routine:
      receivers: [otlp]
      processors: [memory_limiter, filter/drop_health, probabilistic_sampler/debug, transform/trim, batch]
      exporters: [opensearch]

from_attribute: trace_id matters. Logs from the same trace get kept or dropped together, so what remains isn't fragmented. Judge each log record at random instead, and you can end up with only three lines left from one request's logs, useless for investigation.

Splitting pipelines by signal via routing is another approach. Route by severity to different indices with different retention, and a policy that keeps ERROR for 90 days while keeping INFO for only 7 becomes possible.

TierTargetRetentionIndexingRelative cost
HotERROR, WARN, audit events30–90 daysAll fieldsBaseline
WarmBusiness events among INFO14–30 daysCore fields only0.4x
ColdAccess logs on the success path3–7 daysMinimal0.15x
ArchiveRaw copies for regulatory response1–7 yearsNone, object storage0.02x

The bottom row gets forgotten often. If audit requirements mean you can't shorten retention, a structure where those logs sit as raw copies in object storage rather than the search cluster, pulled out only when needed, is far cheaper. Searchability and retention are different requirements.

The Real Cost of Faking Metrics With Logs

"We have all the logs, so why not just compute the error rate from them" is a natural thought, and at small scale it genuinely works. At scale, the cost structure flips.

Let's look at the numbers.

# Scale assumptions
rps            = 5_000          # requests per second
log_bytes      = 800            # size of one indexed log entry (bytes)
seconds_of_day = 86_400

daily_gb = rps * log_bytes * seconds_of_day / 1024**3
print(f"Daily indexed volume: {daily_gb:.1f} GB")
# Daily indexed volume: 321.8 GB

# The same information expressed as metrics
routes, status_classes, pods = 120, 5, 40
series = routes * status_classes * pods
samples_per_day = series * (seconds_of_day / 15)      # 15-second scrape
metric_bytes = samples_per_day * 2                    # roughly 2 bytes per sample after compression
print(f"{series:,} series, {metric_bytes/1024**3:.3f} GB per day")
# 24,000 series, 0.257 GB per day

The gap is over a thousandfold. And this is only looking at storage cost. The real gap widens further at query time.

ItemComputing error rate from logsComputing error rate from metrics
Daily storageHundreds of GBHundreds of MB
A dashboard refreshing every 30 secondsFull-range scan every timeLookup on a precomputed time series
A 30-day comparison queryScans 30 days of documents, tens of secondsHundreds of ms
RetentionCost caps it at 7–14 daysOver a year is realistic
Alert evaluationA heavy aggregation query every minuteCheap vector arithmetic
Behavior during a spikeQueries slow down as much as logs growSeries count stays the same

The last row is the most dangerous. When an incident hits, logs surge, and that's exactly the moment log-based alerts and dashboards get slowest. It's an observability system that doesn't work when you need it.

The right direction isn't cutting logs — it's moving aggregation to metrics and keeping logs for investigation. The collector can do this conversion for you.

# Turn counts from logs into metrics and send them to Prometheus
connectors:
  count:
    logs:
      log.error.count:
        description: Log occurrence count by severity
        conditions:
          - 'severity_number >= 17'
        attributes:
          - key: service.name
          - key: error.type
          - key: http.route

service:
  pipelines:
    logs/in:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [opensearch, count]
    metrics/from_logs:
      receivers: [count]
      processors: [batch]
      exporters: [prometheusremotewrite]

What goes into the attributes list is everything. Whatever's there becomes a metric label as-is, so putting in order.id or tenant.id blows up the series count. A high-cardinality field that was fine in a log becomes a problem the moment it crosses over into a metric.

Failure Modes and Diagnosis

SymptomCauseCheckResponse
Only part of the logs arriveIndexing rejected on a field-type conflictIndex-failure responses, dead lettersFix field types, unify types in the app
Indexing latency keeps climbingMapping-update queue backing upCluster pending-task countAdopt dynamic strict, flat_object
Search suddenly gets much slower for a specific windowA single shard has grown too largePer-index shard sizeSize-based rollover
Old indices never get deletedAn ISM policy failed and was left unattendedThe failed field in ISM explainReapply the failed index, add an alert
Disk suddenly fills upTemporary space during force_mergeMerge job statusSecure headroom before moving to warm
Search works but aggregation doesn'tflat_object sub-keysCheck the mappingPromote keys that need aggregation to explicit fields
Logs get lost during an incidentCollector queue saturationCollector drop counterAdjust queue size and backpressure policy

Pay special attention to the last row. If backpressure from the log pipeline propagates all the way to the app, you end up in a situation where the observability system kills the service. Let the collector drop when its queue is full, and alert on the drop counter. Losing logs is bad, but it's better than a service outage.

exporters:
  opensearch:
    sending_queue:
      enabled: true
      queue_size: 5000
      # Drop new data when the queue is full. Don't push backpressure back to the app
      block_on_overflow: false

Closing — Log Cost Is Set by Field Design, Not Volume

Between two organizations with a tenfold difference in log cost under the same traffic, the difference isn't the compression algorithm. One has fixed field names and has moved its aggregation questions to metrics; the other has dynamic mapping switched on while its dashboards scan raw logs.

There are three checks you can run right now. First, count the fields on your biggest log index. Four digits, and this quarter's project is already decided. Second, pull the list of indices that were actually searched over the last 30 days. Indices that were never searched often make up half the storage. Third, find the dashboard panels that scan logs and check whether they can be moved to metrics.

Further reading.