Split View: 관측 가능성 완전 정복: 로그·트레이싱·LLM 모니터링
관측 가능성 완전 정복: 로그·트레이싱·LLM 모니터링
- 들어가며 — 관측 가능성이란 무엇인가
- 세 기둥 — 로그, 메트릭, 트레이스
- 신호는 어떻게 연결되나 — trace_id와 exemplar
- 로그 — Loki vs OpenSearch
- 분산 트레이싱 — OpenTelemetry가 표준이 되다
- LLM 관측 가능성 — 왜 특별한 도구가 필요한가
- 하나로 합치기 — OpenTelemetry 중심 스택
- 마치며
- 참고 자료
들어가며 — 관측 가능성이란 무엇인가
시스템이 느려졌습니다. 사용자는 "결제가 안 된다"고 합니다. 그런데 서버는 멀쩡히 돌아가고, CPU도 정상이고, 에러 로그도 딱히 없습니다. 대체 어디가 문제일까요? 이 질문에 답하는 능력이 바로 **관측 가능성(observability)**입니다.
관측 가능성은 모니터링과 자주 혼동됩니다. 모니터링이 "미리 정해 둔 지표가 정상 범위인가"를 보는 것이라면, 관측 가능성은 "시스템 바깥에서 던지는 임의의 질문에 내부 상태로 답할 수 있는가"입니다. 전자는 이미 아는 문제(known unknowns)를 감시하고, 후자는 예상치 못한 문제(unknown unknowns)를 파고듭니다.
이 능력을 만드는 재료가 흔히 **세 기둥(three pillars)**이라 불리는 로그, 메트릭, 트레이스입니다. 이 글은 세 기둥을 하나씩 짚고, 로그 저장소(Loki vs OpenSearch)와 분산 트레이싱(OpenTelemetry, Jaeger, Tempo)의 선택지를 대조한 뒤, 요즘 새로 떠오른 LLM 관측 가능성(Langfuse 등)까지 이어서 정리합니다.
세 기둥 — 로그, 메트릭, 트레이스
세 신호는 같은 사건을 서로 다른 각도에서 기록합니다.
- 로그(logs): 특정 시점에 일어난 개별 사건의 기록입니다. "12:03:11에 주문 4821 결제 실패, 이유: 카드 거절." 가장 상세하지만 양이 폭발하기 쉽습니다.
- 메트릭(metrics): 시간에 따라 집계된 수치입니다. "초당 요청 수", "p99 지연시간", "에러율". 저장이 싸고 대시보드·알림에 적합하지만, 개별 사건의 맥락은 없습니다.
- 트레이스(traces): 하나의 요청이 여러 서비스를 거치는 전체 여정입니다. API 게이트웨이에서 시작해 주문 서비스, 결제 서비스, DB를 지나며 각 구간이 얼마나 걸렸는지 보여줍니다.
여기에 최근 **연속 프로파일링(continuous profiling)**이 네 번째 기둥으로 자주 거론됩니다. 프로덕션에서 CPU·메모리를 함수 단위로 상시 샘플링해, "어느 함수가 CPU를 먹는가"를 트레이스보다 더 깊은 코드 레벨에서 답합니다. Grafana Pyroscope, Parca 같은 도구가 대표적입니다.
핵심은 세 신호를 따로 보는 것이 아니라 엮어서 보는 것입니다. 메트릭에서 "에러율이 튀었다"를 발견하고, 그 시점의 트레이스로 넘어가 "결제 서비스가 느렸다"를 확인하고, 그 트레이스에 걸린 로그로 "카드사 타임아웃"이라는 원인을 읽습니다. 이 흐름이 관측 가능성의 정수입니다.
신호는 어떻게 연결되나 — trace_id와 exemplar
세 신호를 엮는 접착제는 상관관계 식별자(correlation ID), 그중에서도 trace_id입니다.
가장 실용적인 연결은 로그에 trace_id를 심는 것입니다. 구조화 로그의 필드로 trace_id를 함께 남기면, 특정 트레이스를 보다가 "이 요청이 남긴 로그만" 필터링할 수 있습니다. 반대로 에러 로그를 보다가 그 trace_id로 전체 트레이스를 열어 볼 수도 있습니다.
{
"timestamp": "2026-07-03T12:03:11.482Z",
"level": "ERROR",
"service": "payment-service",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"message": "card declined",
"order_id": 4821
}
메트릭과 트레이스는 **exemplar(견본)**로 연결됩니다. exemplar는 메트릭 데이터 포인트에 대표 trace_id를 하나 붙여 두는 기법입니다. 예를 들어 "p99 지연시간" 히스토그램의 느린 버킷에 실제 느렸던 요청의 trace_id를 달아 두면, 대시보드에서 그래프의 튀는 지점을 클릭해 바로 그 트레이스로 점프할 수 있습니다. 메트릭(무엇이)에서 트레이스(왜)로 넘어가는 다리입니다.
이 연결이 되도록 만드는 것이 관측 가능성 설계의 절반입니다. 신호가 아무리 많아도 서로 이어지지 않으면, 문제를 만났을 때 세 개의 분리된 창을 눈으로 대조하는 고통이 남습니다.
로그 — Loki vs OpenSearch
로그를 어디에 어떻게 저장하느냐는 비용과 검색 경험을 크게 가릅니다. 대표적인 두 진영이 Grafana Loki와 OpenSearch(구 Elasticsearch 계열)입니다.
Loki — "로그계의 Prometheus"
Grafana Loki의 철학은 한 문장으로 "로그 본문은 인덱싱하지 않는다"입니다. Loki는 로그의 전체 텍스트에 역색인을 만들지 않고, 대신 **레이블(label)**만 인덱싱합니다. 로그 본문 자체는 압축해 값싼 오브젝트 스토리지(S3, GCS 등)에 그대로 던져 둡니다. 그래서 Prometheus가 메트릭을 레이블로 다루듯, Loki는 로그를 레이블로 다룹니다. "로그계의 Prometheus"라는 별명이 여기서 나옵니다.
질의 언어는 LogQL입니다. 먼저 레이블로 로그 스트림을 좁힌 뒤, 그 안에서 텍스트를 필터링합니다.
{app="payment-service", level="error"} |= "card declined" | json | order_id="4821"
여기서 앞의 중괄호 부분이 레이블 셀렉터(인덱스가 걸린 부분)이고, 뒤의 |=부터가 본문 필터입니다. 이 구조 덕분에 인덱스가 작아 저장 비용이 낮고 운영이 가볍습니다. 대신 레이블로 충분히 좁히지 못한 채 방대한 기간을 전문검색하면 느려질 수 있습니다. 레이블 설계가 성패를 가릅니다.
주의할 함정은 높은 카디널리티(high cardinality) 레이블입니다. user_id나 trace_id처럼 값의 종류가 폭발하는 것을 레이블로 쓰면, 스트림이 수백만 개로 쪼개져 Loki가 급격히 느려집니다. 그런 값은 레이블이 아니라 로그 본문에 넣고 필터로 찾아야 합니다.
OpenSearch — 강력한 전문검색
OpenSearch(그리고 그 뿌리인 Elasticsearch)는 정반대 접근입니다. 로그의 거의 모든 필드에 **역색인(inverted index)**을 만듭니다. 덕분에 임의의 필드로 복잡한 전문검색, 집계, 패싯 분석을 빠르게 할 수 있고, Kibana/OpenSearch Dashboards라는 강력한 탐색 UI가 붙습니다.
같은 질의를 OpenSearch 쿼리 DSL로 쓰면 이런 모습입니다.
{
"query": {
"bool": {
"must": [
{ "match": { "service": "payment-service" } },
{ "match": { "message": "card declined" } },
{ "term": { "order_id": 4821 } }
]
}
}
}
대가는 무게입니다. 모든 것을 인덱싱하니 저장 공간과 메모리를 많이 쓰고, 클러스터 운영(샤드, 힙, 리밸런싱)도 손이 많이 갑니다. 로그 양이 커질수록 인프라 비용이 가파르게 오릅니다.
언제 무엇을 고를까
- Loki: 비용에 민감하고, 로그를 주로 "레이블로 좁혀 최근 구간을 훑는" 방식으로 쓰며, 이미 Grafana·Prometheus 생태계를 쓰고 있다면 자연스럽습니다.
- OpenSearch: 임의 필드로 강력한 전문검색과 복잡한 집계·분석이 자주 필요하고(보안 로그 분석, 감사, SIEM 등), 그만한 인프라 비용을 감당할 수 있다면 강력합니다.
구조화 로깅과 로그 레벨
어느 저장소를 쓰든 공통 원칙이 있습니다. 로그는 사람이 읽는 문장이 아니라 **기계가 파싱할 구조화된 데이터(JSON)**로 남기는 것이 좋습니다. "2026-07-03 payment failed for order 4821" 같은 문자열보다, 위에서 본 것처럼 필드가 나뉜 JSON이 필터·집계에 압도적으로 유리합니다.
로그 레벨(DEBUG, INFO, WARN, ERROR)은 소음을 통제하는 첫 번째 다이얼입니다. 그리고 트래픽이 큰 서비스라면 샘플링이 필수입니다. 성공한 요청의 INFO 로그를 100% 남길 필요는 없습니다. 예를 들어 정상 요청은 1%만 샘플링하고, 에러는 100% 남기는 식으로, 비용과 정보량의 균형을 잡습니다.
분산 트레이싱 — OpenTelemetry가 표준이 되다
마이크로서비스에서 하나의 사용자 요청은 수많은 서비스를 거칩니다. 이 여정을 재구성하는 것이 분산 트레이싱이고, 이 분야의 사실상 표준이 **OpenTelemetry(OTel)**입니다.
왜 OpenTelemetry인가
OpenTelemetry는 CNCF 산하의 벤더 중립 표준입니다. 예전에는 관측 도구마다 계측 SDK가 따로여서, 백엔드를 바꾸면 코드의 계측을 전부 갈아엎어야 했습니다. OTel은 이 계측 계층을 표준화했습니다. 코드는 OTel로 한 번만 계측하고, 데이터를 어디로 보낼지(Jaeger, Tempo, 상용 벤더 등)는 나중에 익스포터 설정만 바꿔 정합니다. 잠금(lock-in)을 푸는 것이 핵심 가치입니다.
트레이싱의 기본 개념은 이렇습니다.
- 트레이스(trace): 하나의 요청 전체 여정. 고유한 trace_id로 식별됩니다.
- 스팬(span): 트레이스를 이루는 하나의 작업 단위(예: "DB 쿼리", "결제 API 호출"). 시작·종료 시각을 가집니다.
- 부모-자식 관계: 스팬은 중첩됩니다. "주문 처리" 스팬 안에 "재고 확인", "결제" 스팬이 자식으로 들어갑니다. 이 트리가 트레이스의 형태를 만듭니다.
- 속성(attributes)과 이벤트(events): 스팬에 붙이는 키-값 메타데이터(예: http.method, db.system)와 스팬 수명 중 일어난 시점 기록입니다.
컨텍스트 전파 — traceparent 헤더
서비스 A가 서비스 B를 호출할 때, 두 서비스의 스팬이 같은 트레이스로 이어지려면 trace_id가 넘어가야 합니다. 이 **컨텍스트 전파(context propagation)**의 표준이 W3C Trace Context, 즉 HTTP 요청에 실리는 traceparent 헤더입니다. 값은 대략 버전, trace_id, 부모 span_id, 플래그를 하이픈으로 이은 형태입니다.
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
버전 trace_id (128비트) 부모 span_id 플래그(샘플됨)
수신 서비스는 이 헤더를 읽어 자기 스팬을 같은 trace_id 아래 자식으로 붙입니다. 이렇게 서비스 경계를 넘어 트레이스가 하나로 이어집니다.
OTel Collector와 계측 방식
OTel Collector는 애플리케이션과 백엔드 사이에 두는 독립 프로세스입니다. 여러 서비스가 보낸 텔레메트리를 받아서(receive), 가공하고(process: 배치, 샘플링, 속성 편집), 원하는 백엔드로 내보냅니다(export). 앱은 Collector 하나만 알면 되고, 백엔드 교체·다중 전송·샘플링 정책을 Collector에서 중앙집중식으로 관리합니다.
계측에는 두 방식이 있습니다.
- 자동 계측(auto-instrumentation): 언어 에이전트가 인기 라이브러리(HTTP 서버, DB 드라이버, gRPC 등)를 자동으로 후킹해 스팬을 만들어 줍니다. 코드 수정이 거의 없어 빠르게 시작할 수 있습니다.
- 수동 계측(manual instrumentation): SDK로 직접 스팬을 열고 속성을 붙입니다. 비즈니스 로직의 의미 있는 구간을 세밀하게 잡을 때 필요합니다.
최소한의 OTel SDK 초기화는 대략 이런 모습입니다(Node.js 예).
const { NodeSDK } = require("@opentelemetry/sdk-node");
const {
getNodeAutoInstrumentations,
} = require("@opentelemetry/auto-instrumentations-node");
const {
OTLPTraceExporter,
} = require("@opentelemetry/exporter-trace-otlp-http");
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: "http://otel-collector:4318/v1/traces",
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
백엔드 — Jaeger와 Tempo
수집된 트레이스는 트레이싱 백엔드에 저장·조회합니다.
- Jaeger: Uber에서 출발한 성숙한 오픈소스 트레이싱 시스템입니다. 자체 UI로 트레이스 검색·의존성 그래프·타임라인을 제공합니다.
- Grafana Tempo: Loki의 트레이스판에 해당합니다. 트레이스를 인덱싱하지 않고 값싼 오브젝트 스토리지에 통째로 저장해 비용을 크게 낮춥니다. trace_id로 조회하는 것이 기본이며, Loki·Prometheus와 Grafana에서 매끄럽게 연동됩니다.
샘플링 — head vs tail
트레이스를 100% 저장하면 양과 비용이 감당이 안 됩니다. 그래서 샘플링을 합니다.
- 헤드 샘플링(head-based): 트레이스 시작 시점에 "이건 저장, 저건 버림"을 즉시 결정합니다. 예를 들어 "1%만 저장". 단순하고 싸지만, 정작 문제 있는(느리거나 에러 난) 트레이스를 버릴 수 있습니다.
- 테일 샘플링(tail-based): 트레이스가 끝난 뒤 전체를 보고 결정합니다. "에러가 있거나 p99보다 느린 트레이스는 무조건 저장" 같은 규칙을 걸 수 있어, 정상 트래픽은 버리고 문제 트래픽은 확실히 남깁니다. 대신 트레이스가 끝날 때까지 버퍼링해야 하므로 Collector에 부하와 메모리가 더 듭니다.
LLM 관측 가능성 — 왜 특별한 도구가 필요한가
여기서 요즘 급부상한 새 영역으로 넘어갑니다. LLM 애플리케이션(챗봇, RAG, 에이전트)은 기존 관측 도구만으로는 부족합니다. 이유가 분명합니다.
- 비결정성: 같은 입력에도 출력이 매번 다릅니다. "성공/실패"라는 이진 판단으로는 품질을 잡을 수 없습니다.
- 프롬프트·버전 드리프트: 프롬프트를 한 줄 바꿨더니 품질이 무너지는 일이 흔합니다. 어떤 프롬프트 버전이 어떤 결과를 냈는지 추적해야 합니다.
- 비용: LLM 호출은 토큰 단위로 과금됩니다. 어떤 요청이, 어떤 체인 단계가 토큰을 얼마나 먹는지 봐야 비용을 통제합니다.
- 품질 평가: 지연시간이 정상이어도 답이 틀리거나 헛것을 지어낼(hallucination) 수 있습니다. 정답 여부를 별도로 평가(eval)해야 합니다.
- RAG 디버깅: 답이 이상할 때, 원인이 검색(retrieval)이 엉뚱한 문서를 물어온 탓인지, 생성(generation)이 문서를 무시한 탓인지 구분해야 합니다.
Langfuse — LLM 호출의 트레이스
Langfuse는 이 문제를 정면으로 다루는 오픈소스 LLM 관측 플랫폼입니다. 개념적으로는 분산 트레이싱과 닮았습니다. 하나의 사용자 상호작용을 하나의 트레이스로 잡고, 그 안의 각 단계를 중첩된 스팬으로 기록합니다. 다만 스팬의 내용이 LLM에 특화되어 있습니다. 각 LLM 호출마다 프롬프트, 완성 결과, 토큰 사용량, 비용, 지연시간을 담고, 체인·에이전트·검색 단계를 부모-자식으로 중첩합니다.
RAG 파이프라인 하나의 트레이스 형태를 아주 단순화하면 이렇습니다.
{
"trace_id": "t_9f2c",
"name": "rag-chat",
"input": "환불 정책이 어떻게 되나요?",
"spans": [
{
"name": "retrieval",
"type": "retriever",
"input": "환불 정책",
"output": ["doc-12", "doc-45"],
"latency_ms": 82
},
{
"name": "llm-answer",
"type": "generation",
"model": "claude-x",
"prompt_tokens": 1240,
"completion_tokens": 180,
"cost_usd": 0.0042,
"latency_ms": 1310
}
],
"scores": [{ "name": "helpfulness", "value": 0.9 }]
}
Langfuse가 주는 핵심 기능은 이렇습니다.
- 중첩 트레이스: 체인·에이전트·툴 호출·검색을 계층 구조로 시각화합니다. 에이전트가 어떤 도구를 몇 번 불렀는지 한눈에 보입니다.
- 프롬프트 버전 관리: 프롬프트를 버전별로 저장·배포하고, 어떤 버전이 어떤 트레이스를 냈는지 연결합니다.
- 평가와 점수(evals/scores): 트레이스에 점수를 붙입니다. LLM-as-a-judge 자동 평가, 규칙 기반 점수, 사람 라벨링을 모두 담을 수 있습니다.
- 사용자 피드백: 최종 사용자의 좋아요/싫어요 같은 피드백을 해당 트레이스에 연결해 실제 품질 신호로 씁니다.
다른 선택지들 — LangSmith, Helicone, Phoenix
Langfuse만 있는 것은 아닙니다.
- LangSmith: LangChain 팀이 만든 상용 플랫폼입니다. LangChain/LangGraph와 긴밀하게 통합되고, 트레이싱·평가·데이터셋 관리가 촘촘합니다.
- Helicone: 프록시 방식이 특징입니다. LLM API 앞에 프록시로 얹으면 코드 변경을 최소화하면서 로깅·캐싱·비용 추적을 붙일 수 있습니다.
- Arize Phoenix: 오픈소스이며 OpenTelemetry 기반(OpenInference)이라는 점이 강점입니다. 트레이싱과 평가에 더해, 임베딩·드리프트 분석 같은 ML 관측 기능이 강합니다.
고르는 기준은 대체로 이렇습니다. LangChain 중심이면 LangSmith, 코드 변경 없이 빠르게 붙이려면 Helicone, 오픈소스와 OTel 표준을 중시하면 Phoenix나 Langfuse가 자연스럽습니다.
하나로 합치기 — OpenTelemetry 중심 스택
이제 전체를 하나의 그림으로 엮어 봅시다. 요즘 흔한 구성은 OpenTelemetry를 허브로 두는 것입니다.
앱(서비스들) ── OTel SDK ──▶ [ OTel Collector ]
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
[ Loki (로그) ] [ Tempo (트레이스) ] [ Prometheus (메트릭) ]
└───────────── Grafana 로 통합 조회 ──────────────┘
LLM 호출 ─────────────────▶ [ Langfuse (LLM 트레이스·비용·평가) ]
핵심 원리는 앞서 강조한 trace_id 상관관계입니다. 앱은 OTel로 계측해 Collector로 보내고, Collector가 로그는 Loki, 트레이스는 Tempo, 메트릭은 Prometheus로 나눠 보냅니다. Grafana에서 셋을 한 화면에 놓고, trace_id로 로그↔트레이스를, exemplar로 메트릭→트레이스를 오갑니다. LLM 부분은 Langfuse가 별도로 잡되, 같은 trace_id를 실어 두면 일반 트레이스와도 이을 수 있습니다.
자주 걸리는 함정 — 비용과 카디널리티
- 카디널리티 폭발: 메트릭 레이블이나 Loki 레이블에 user_id, request_id, trace_id처럼 값이 무한히 늘어나는 것을 넣으면 시계열·스트림이 폭증해 저장소가 무너집니다. 고카디널리티 값은 로그 본문이나 트레이스 속성에 넣고, 레이블은 종류가 제한된 것(서비스명, 지역, 상태코드 등)만 씁니다.
- 무분별한 100% 저장: 모든 로그·트레이스를 전량 저장하면 비용이 감당 안 됩니다. 정상은 샘플링하고 문제(에러·느린 요청)는 확실히 남기는 정책을 초기에 세웁니다.
- 연결되지 않은 신호: trace_id를 로그에 심지 않으면 세 기둥이 따로 놉니다. 계측 초기에 trace_id 주입을 반드시 넣으세요.
- LLM 비용 방치: 프롬프트가 길어지거나 재시도가 늘면 토큰 비용이 조용히 폭증합니다. Langfuse 같은 도구로 요청·단계별 비용을 상시 관찰합니다.
결정 체크리스트
- 로그 저장소: 비용·레이블 중심이면 Loki, 강력한 전문검색·분석이면 OpenSearch.
- 계측: 새로 시작한다면 처음부터 OpenTelemetry로. 벤더 잠금을 피할 수 있습니다.
- 트레이스 백엔드: 저비용·Grafana 생태계면 Tempo, 독립적이고 성숙한 UI면 Jaeger.
- 샘플링: 문제 트레이스를 놓치기 싫다면 테일 샘플링, 단순·저비용이면 헤드 샘플링.
- LLM: LLM 앱을 운영한다면 별도로 Langfuse/LangSmith/Helicone/Phoenix 중 하나를 반드시 도입.
- 상관관계: 무엇을 쓰든 trace_id를 모든 로그에 심어 신호를 잇기.
마치며
관측 가능성은 도구 하나로 끝나는 것이 아니라, 로그·메트릭·트레이스라는 세 기둥(그리고 떠오르는 프로파일링)을 trace_id로 엮어 "임의의 질문에 답할 수 있는 시스템"을 만드는 일입니다. 로그는 Loki와 OpenSearch가 비용과 검색력의 양극을 이루고, 트레이싱은 OpenTelemetry가 표준을 통일하며 Jaeger·Tempo가 이를 받아 주고, 그 위에 LLM이라는 새 층을 Langfuse 같은 도구가 채워 갑니다.
핵심은 "무엇이 최고인가"가 아니라 "무엇이 이 시스템에 맞는가", 그리고 무엇보다 "신호들이 서로 연결되어 있는가"입니다. 세 기둥을 각각 잘 세우고 trace_id로 단단히 이어 두면, 다음번 새벽 3시의 장애에서 세 개의 창을 눈으로 대조하는 대신, 한 번의 클릭으로 원인까지 내려갈 수 있습니다.
참고 자료
- OpenTelemetry 공식 문서: https://opentelemetry.io/docs/
- W3C Trace Context 표준: https://www.w3.org/TR/trace-context/
- Grafana Loki 문서: https://grafana.com/docs/loki/latest/
- Grafana Tempo 문서: https://grafana.com/docs/tempo/latest/
- Jaeger 공식 사이트: https://www.jaegertracing.io/
- OpenSearch 문서: https://opensearch.org/docs/latest/
- Langfuse 문서: https://langfuse.com/docs
- Arize Phoenix: https://docs.arize.com/phoenix
Observability Deep Dive: Logs, Tracing, and LLM Monitoring
- Introduction — What Observability Really Means
- The Three Pillars — Logs, Metrics, Traces
- How Signals Connect — trace_id and Exemplars
- Logs — Loki vs OpenSearch
- Distributed Tracing — OpenTelemetry Becomes the Standard
- LLM Observability — Why It Needs Special Tools
- Putting It Together — An OpenTelemetry-Centered Stack
- Conclusion
- References
Introduction — What Observability Really Means
The system got slow. Users say "payments aren't going through." But the servers are running fine, CPU looks normal, and there are no obvious error logs. Where is the problem? The ability to answer that question is exactly what observability is.
Observability is often confused with monitoring. Monitoring watches whether predefined metrics stay in their normal range. Observability is whether you can answer arbitrary questions about a system's internal state from the outside. The former guards against known unknowns; the latter lets you dig into unknown unknowns.
The raw materials for this ability are commonly called the three pillars: logs, metrics, and traces. This post walks through each pillar, contrasts the choices for log storage (Loki vs OpenSearch) and distributed tracing (OpenTelemetry, Jaeger, Tempo), and then moves on to the newly emerging field of LLM observability (Langfuse and friends).
The Three Pillars — Logs, Metrics, Traces
The three signals record the same events from different angles.
- Logs: records of individual events at a point in time. "At 12:03:11, payment for order 4821 failed, reason: card declined." The most detailed, but the volume explodes easily.
- Metrics: numeric values aggregated over time. "Requests per second," "p99 latency," "error rate." Cheap to store and ideal for dashboards and alerts, but they carry no context about individual events.
- Traces: the full journey of a single request across many services. Starting at the API gateway, passing through the order service, payment service, and the database, showing how long each segment took.
Recently, continuous profiling is frequently cited as a fourth pillar. It continuously samples CPU and memory at the function level in production, answering "which function is eating the CPU" at a code level deeper than traces. Tools like Grafana Pyroscope and Parca are representative.
The key is not to view the three signals in isolation but to view them linked together. You spot "the error rate spiked" in a metric, jump to the trace at that moment to confirm "the payment service was slow," and read the log attached to that trace to find the cause: "card issuer timeout." This flow is the essence of observability.
How Signals Connect — trace_id and Exemplars
The glue that links the three signals is a correlation ID, above all the trace_id.
The most practical link is embedding the trace_id in logs. If you emit the trace_id as a field of your structured logs, you can look at a trace and filter down to "only the logs this request emitted." Conversely, from an error log you can open the full trace by its trace_id.
{
"timestamp": "2026-07-03T12:03:11.482Z",
"level": "ERROR",
"service": "payment-service",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"message": "card declined",
"order_id": 4821
}
Metrics and traces connect through exemplars. An exemplar attaches one representative trace_id to a metric data point. For example, if you attach the trace_id of an actually-slow request to the slow bucket of a "p99 latency" histogram, you can click the spiking point on a dashboard and jump straight to that trace. It is a bridge from metrics (what) to traces (why).
Making these connections work is half of observability design. No matter how many signals you have, if they aren't linked, you're left with the pain of eyeballing three separate windows whenever a problem hits.
Logs — Loki vs OpenSearch
Where and how you store logs strongly shapes both cost and the search experience. The two representative camps are Grafana Loki and OpenSearch (the former Elasticsearch lineage).
Loki — "Prometheus for Logs"
Grafana Loki's philosophy is, in one sentence, "don't index the log body." Loki builds no inverted index over the full text of logs; instead it indexes only the labels. The log body itself is compressed and thrown into cheap object storage (S3, GCS, and the like) as-is. So just as Prometheus treats metrics by labels, Loki treats logs by labels. That's where the nickname "Prometheus for logs" comes from.
The query language is LogQL. You first narrow the log streams by label, then filter text within them.
{app="payment-service", level="error"} |= "card declined" | json | order_id="4821"
Here the leading curly-brace part is the label selector (the indexed part), and everything from |= onward is a body filter. Thanks to this structure the index stays small, so storage cost is low and operation is light. On the other hand, if you don't narrow enough by label and full-text search a vast time range, it can get slow. Label design determines success or failure.
The trap to watch for is high-cardinality labels. If you use something whose value space explodes — like user_id or trace_id — as a label, the streams shard into millions and Loki degrades sharply. Such values belong in the log body, found via a filter, not as labels.
OpenSearch — Powerful Full-Text Search
OpenSearch (and its root, Elasticsearch) takes the opposite approach. It builds an inverted index over nearly every field of a log. This makes complex full-text search, aggregation, and faceted analysis over arbitrary fields fast, and it comes with a powerful exploration UI in Kibana / OpenSearch Dashboards.
The same query written in the OpenSearch query DSL looks like this.
{
"query": {
"bool": {
"must": [
{ "match": { "service": "payment-service" } },
{ "match": { "message": "card declined" } },
{ "term": { "order_id": 4821 } }
]
}
}
}
The price is weight. Because it indexes everything, it uses a lot of storage and memory, and cluster operation (shards, heap, rebalancing) is hands-on. As log volume grows, infrastructure cost climbs steeply.
When to Choose Which
- Loki: natural if you're cost-sensitive, use logs mostly by "narrowing by label and scanning recent ranges," and already live in the Grafana / Prometheus ecosystem.
- OpenSearch: powerful if you frequently need strong full-text search and complex aggregation/analysis over arbitrary fields (security log analysis, auditing, SIEM), and can afford the infrastructure cost.
Structured Logging and Log Levels
Whichever storage you pick, there's a shared principle. Logs are better emitted not as human-readable sentences but as structured data (JSON) for machines to parse. Compared to a string like "2026-07-03 payment failed for order 4821", the field-separated JSON above is overwhelmingly better for filtering and aggregation.
Log levels (DEBUG, INFO, WARN, ERROR) are the first dial for controlling noise. And for high-traffic services, sampling is essential. You don't need to keep 100% of the INFO logs of successful requests. For instance, sample only 1% of normal requests while keeping 100% of errors, striking a balance between cost and information.
Distributed Tracing — OpenTelemetry Becomes the Standard
In microservices, a single user request passes through many services. Reconstructing that journey is distributed tracing, and the de facto standard here is OpenTelemetry (OTel).
Why OpenTelemetry
OpenTelemetry is a vendor-neutral standard under the CNCF. Previously, every observability tool had its own instrumentation SDK, so switching backends meant rewriting all your instrumentation. OTel standardized this instrumentation layer. You instrument your code with OTel once, and decide where the data goes (Jaeger, Tempo, a commercial vendor, etc.) later, by changing only exporter configuration. Breaking lock-in is its core value.
The basic concepts of tracing are these.
- Trace: the full journey of one request. Identified by a unique trace_id.
- Span: one unit of work that makes up a trace (e.g., "DB query," "call payment API"). It has start and end times.
- Parent-child relationship: spans nest. Inside an "order processing" span, "check inventory" and "payment" spans go in as children. This tree gives a trace its shape.
- Attributes and events: key-value metadata attached to a span (e.g., http.method, db.system) and timestamped records of things that happened during a span's life.
Context Propagation — the traceparent Header
When service A calls service B, the trace_id must cross over for the two services' spans to belong to the same trace. The standard for this context propagation is W3C Trace Context — that is, the traceparent header carried on the HTTP request. Its value is roughly the version, trace_id, parent span_id, and flags joined by hyphens.
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
version trace_id (128-bit) parent span_id flags (sampled)
The receiving service reads this header and attaches its own span as a child under the same trace_id. That's how a trace stays unified across service boundaries.
The OTel Collector and Instrumentation
The OTel Collector is a standalone process placed between your applications and your backends. It receives telemetry sent by many services, processes it (batching, sampling, editing attributes), and exports it to the backends you want. Apps only need to know about the Collector, and you manage backend swaps, multi-destination fan-out, and sampling policy centrally in the Collector.
There are two styles of instrumentation.
- Auto-instrumentation: a language agent automatically hooks popular libraries (HTTP servers, DB drivers, gRPC, etc.) and creates spans for you. With almost no code changes, you can get started quickly.
- Manual instrumentation: you open spans and attach attributes directly via the SDK. Needed when you want to capture meaningful segments of business logic in detail.
A minimal OTel SDK initialization looks roughly like this (Node.js example).
const { NodeSDK } = require("@opentelemetry/sdk-node");
const {
getNodeAutoInstrumentations,
} = require("@opentelemetry/auto-instrumentations-node");
const {
OTLPTraceExporter,
} = require("@opentelemetry/exporter-trace-otlp-http");
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: "http://otel-collector:4318/v1/traces",
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
Backends — Jaeger and Tempo
Collected traces are stored and queried in a tracing backend.
- Jaeger: a mature open-source tracing system that started at Uber. Its own UI offers trace search, dependency graphs, and timelines.
- Grafana Tempo: the trace counterpart to Loki. It doesn't index traces but stores them whole in cheap object storage, drastically lowering cost. Lookup by trace_id is the default, and it integrates smoothly with Loki, Prometheus, and Grafana.
Sampling — Head vs Tail
Storing 100% of traces makes volume and cost unmanageable, so you sample.
- Head-based sampling: decides "keep this, drop that" immediately at the start of a trace. For example, "keep only 1%." Simple and cheap, but it can drop the very traces that had problems (slow or errored).
- Tail-based sampling: decides after the trace finishes, having seen the whole thing. You can set rules like "always keep traces that have an error or are slower than p99," dropping normal traffic while reliably keeping problematic traffic. In exchange, you must buffer until the trace ends, which costs the Collector more load and memory.
LLM Observability — Why It Needs Special Tools
Now we move to a new field that has surged recently. LLM applications (chatbots, RAG, agents) aren't well served by traditional observability tools alone. The reasons are clear.
- Non-determinism: the same input yields different output every time. A binary "success/failure" judgment can't capture quality.
- Prompt and version drift: changing a prompt by one line and watching quality collapse is common. You need to track which prompt version produced which result.
- Cost: LLM calls are billed per token. You need to see which request, which chain step, eats how many tokens to control cost.
- Quality evaluation: even when latency is normal, the answer can be wrong or hallucinated. Correctness must be evaluated separately (evals).
- RAG debugging: when an answer is off, you must distinguish whether retrieval fetched the wrong documents or generation ignored the documents.
Langfuse — Traces of LLM Calls
Langfuse is an open-source LLM observability platform that tackles this head-on. Conceptually it resembles distributed tracing: it captures one user interaction as one trace and records each step inside as nested spans. But the span content is LLM-specific. Each LLM call carries the prompt, completion, token usage, cost, and latency, and chain, agent, and retrieval steps nest as parent-child.
A heavily simplified shape of a single RAG pipeline trace looks like this.
{
"trace_id": "t_9f2c",
"name": "rag-chat",
"input": "What is your refund policy?",
"spans": [
{
"name": "retrieval",
"type": "retriever",
"input": "refund policy",
"output": ["doc-12", "doc-45"],
"latency_ms": 82
},
{
"name": "llm-answer",
"type": "generation",
"model": "claude-x",
"prompt_tokens": 1240,
"completion_tokens": 180,
"cost_usd": 0.0042,
"latency_ms": 1310
}
],
"scores": [{ "name": "helpfulness", "value": 0.9 }]
}
The core features Langfuse gives you are these.
- Nested traces: visualizes chains, agents, tool calls, and retrieval as a hierarchy. You see at a glance which tools an agent called and how many times.
- Prompt version management: store and deploy prompts by version, and link which version produced which trace.
- Evals and scores: attach scores to traces. LLM-as-a-judge automated evaluation, rule-based scores, and human labeling can all live here.
- User feedback: connect end-user feedback like thumbs up/down to the relevant trace and use it as a real quality signal.
Other Options — LangSmith, Helicone, Phoenix
Langfuse isn't the only choice.
- LangSmith: a commercial platform built by the LangChain team. It integrates tightly with LangChain/LangGraph and offers dense tracing, evaluation, and dataset management.
- Helicone: distinguished by a proxy approach. Placing it as a proxy in front of your LLM API lets you add logging, caching, and cost tracking with minimal code changes.
- Arize Phoenix: open source, and its strength is being OpenTelemetry-based (OpenInference). On top of tracing and evaluation, it's strong on ML observability like embedding and drift analysis.
The selection criteria are roughly this. LangChain-centric, go LangSmith; add quickly with no code changes, Helicone; value open source and the OTel standard, and Phoenix or Langfuse feel natural.
Putting It Together — An OpenTelemetry-Centered Stack
Now let's tie the whole thing into one picture. A common setup today puts OpenTelemetry at the hub.
apps (services) ── OTel SDK ──▶ [ OTel Collector ]
│
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
[ Loki (logs) ] [ Tempo (traces) ] [ Prometheus (metrics) ]
└───────────── unified query in Grafana ──────────────┘
LLM calls ────────────────▶ [ Langfuse (LLM traces, cost, evals) ]
The core principle is the trace_id correlation stressed earlier. Apps are instrumented with OTel and send to the Collector; the Collector fans logs out to Loki, traces to Tempo, and metrics to Prometheus. In Grafana you place all three on one screen and move from log to trace by trace_id, and from metric to trace by exemplar. The LLM part is captured separately by Langfuse, but if you carry the same trace_id, you can link it to your ordinary traces too.
Common Traps — Cost and Cardinality
- Cardinality explosion: putting values that grow without bound — user_id, request_id, trace_id — into metric labels or Loki labels blows up the time series and streams and topples your storage. Put high-cardinality values in the log body or trace attributes, and keep only bounded-value things (service name, region, status code) as labels.
- Reckless 100% retention: storing every log and trace in full becomes unaffordable. Set a policy early that samples the normal and reliably keeps the problematic (errors, slow requests).
- Unconnected signals: if you don't embed the trace_id in logs, the three pillars drift apart. Make sure to inject the trace_id early in instrumentation.
- Neglected LLM cost: as prompts grow longer or retries increase, token cost quietly explodes. Continuously watch per-request and per-step cost with a tool like Langfuse.
Decision Checklist
- Log storage: Loki for cost- and label-centric use; OpenSearch for powerful full-text search and analysis.
- Instrumentation: if starting fresh, go OpenTelemetry from the start. It keeps you free of vendor lock-in.
- Trace backend: Tempo for low cost and the Grafana ecosystem; Jaeger for a standalone, mature UI.
- Sampling: tail-based if you hate missing problem traces; head-based for simplicity and low cost.
- LLM: if you run an LLM app, definitely adopt one of Langfuse / LangSmith / Helicone / Phoenix.
- Correlation: whatever you use, embed the trace_id in every log to link the signals.
Conclusion
Observability isn't finished by any single tool. It's the work of weaving the three pillars — logs, metrics, and traces (plus the rising profiling) — together by trace_id to build "a system that can answer arbitrary questions." For logs, Loki and OpenSearch form the two poles of cost and search power; for tracing, OpenTelemetry unifies the standard while Jaeger and Tempo receive it; and on top, tools like Langfuse fill in the new LLM layer.
The point is not "what is best" but "what fits this system," and above all, "are the signals connected to each other?" If you stand up the three pillars well and bind them firmly by trace_id, then in the next 3 a.m. incident, instead of eyeballing three windows, you can click your way down to the root cause.
References
- OpenTelemetry official docs: https://opentelemetry.io/docs/
- W3C Trace Context spec: https://www.w3.org/TR/trace-context/
- Grafana Loki docs: https://grafana.com/docs/loki/latest/
- Grafana Tempo docs: https://grafana.com/docs/tempo/latest/
- Jaeger official site: https://www.jaegertracing.io/
- OpenSearch docs: https://opensearch.org/docs/latest/
- Langfuse docs: https://langfuse.com/docs
- Arize Phoenix: https://docs.arize.com/phoenix