Observability를 baseline 깨지 않고 추가하는 패턴
결론: trace 백엔드는 추가되는 surface일 뿐, 파이프라인 행동을 바꾸면 안 된다. 결정적 noop default + lazy import + fail-closed 예외 처리 — 세 장치를 동시에 거는 한 가지 패턴으로 LangFuse, OTel, 미래의 어떤 백엔드도 같은 모양으로 들어온다.
왜 이 결정이 필요했는가
BidMate-DocAgent는 retrieval → verifier → grounded answer 파이프라인이다. rag_core.py는 이미 _StageTimer 컨텍스트 매니저로 stage별 latency를 모으고 diagnostics.stage_latency에 노출한다. ADR 0011 LLM 합성은 diagnostics.synthesis.{tokens_in, tokens_out, latency_ms} 까지 더했다.
부족한 건 sink — 리뷰어나 on-call 엔지니어가 query별 stage 분해, 토큰 수, retry 패턴, 실패 모드를 시간 축으로 볼 수 있는 trace viewer다. Applied AI / LLM Ops 포트폴리오에서 trace viewer는 가장 결정적인 운영 시그널이다. “답변을 반환한다” 와 “production에서 운영할 수 있다” 사이의 차이가 여기서 생긴다.
순진한 접근: pipeline에 직접 bundle
가장 간단한 방법은 run_rag_query 안에 LangFuse SDK를 직접 호출하는 것이다.
def run_rag_query(query, ...):
with langfuse.trace(name="rag_query") as t:
with t.span("retrieve"):
...
with t.span("verify"):
...
이걸 채택하지 않은 이유는 세 가지 ADR 위반이다:
- ADR 0001 (baseline 보존):
naive_baseline호출이 LangFuse 네트워크 의존성을 갖게 된다. CI 결정성과 offline 재현성이 모두 깨진다. - ADR 0005 (eval split): exporter 한 번 죽으면 public CI가 통째로 fail한다. 평가 surface가 외부 의존성에 노출된다.
- ADR 0003 (answer contract): trace_url을 어디에 둘 것인가?
answer.trace_url이면 schema_version이 깨진다.diagnostics로 분리하려면 어차피 추가 surface가 필요하다.
그리고 더 본질적으로 — 두 번째 백엔드를 추가할 때 또 run_rag_query를 편집해야 한다. 결합이 잘못된 자리에 있는 것이다.
패턴: pluggable backend registry
대신 ADR 0013은 rag_observability.py로 분리하고, 환경변수 BIDMATE_TRACE_BACKEND로 게이트한다.
# rag_observability.py
_BACKENDS = {
"none": _NoopTraceContext,
"langfuse": _LangfuseTraceContext,
"otel": _OtelTraceContext,
}
def make_trace_context(backend: str | None = None) -> TraceContext:
backend = backend or os.environ.get("BIDMATE_TRACE_BACKEND", "none")
factory = _BACKENDS.get(backend, _NoopTraceContext)
try:
return factory()
except ImportError as e:
return _NoopTraceContext(unavailable_reason=f"missing_dependency:{e.name}")
except Exception as e:
return _NoopTraceContext(unavailable_reason=f"backend_init_error:{e}")
이 모양은 익숙해야 한다 — ADR 0006의 LLM judge, ADR 0011의 synthesis도 같은 registry 패턴이다. judge → synthesis → trace, 세 곳에 같은 한 가지 추상화가 적용되어 있다. 코드베이스 전체에서 “pluggable backend는 어떻게 추가하는가”의 답이 하나다.
_StageTimer는 단지 trace= kwarg를 받아서, non-noop이면 자식 span을 연다.
# rag_core.py
class _StageTimer:
def __init__(self, name, trace=None):
self.name = name
self.trace = trace
def __enter__(self):
self._t0 = time.perf_counter()
if self.trace is not None:
self._span = self.trace.span(name=self.name)
try:
self._span.__enter__()
except Exception as e:
self._span = None # fail-closed
return self
run_rag_query 본체는 한 줄도 백엔드를 알 필요가 없다. baseline 케이스(BIDMATE_TRACE_BACKEND=none)에서는 _NoopTraceContext.span()이 contextlib.nullcontext()를 반환하므로 stage timer는 동작한다 — 단지 추가 비용이 0일 뿐이다.
Fail-closed contract — 이 surface의 정의
이 패턴의 정의적 속성은 한 줄로 표현된다: 어떤 observability 실패도 query path를 깨지 않는다.
| 실패 | 행동 |
|---|---|
| Optional dep 누락 | trace_backend=none, trace_unavailable_reason="missing_dependency:langfuse" |
| Credentials 누락 | trace_backend=none, trace_unavailable_reason="missing_credentials:langfuse" |
| Backend 생성 실패 | trace_backend=none, trace_unavailable_reason="backend_init_error:..." |
start_trace 예외 |
trace_url=None, trace_error="start_trace:...", query 정상 진행 |
span() 예외 (mid-pipeline) |
_StageTimer.__exit__에서 swallow, 다음 stage span 시도 |
finish() 예외 |
trace_url=None, trace_error="finish:..." |
핵심 invariant: 어떤 실패 모드를 inject해도 결과는 noop run과 byte-identical(trace_* 키와 timing 변동성을 제거하면). 이 invariant는 tests/test_observability_tracing.py의 test_start_trace_exception_falls_back로 잠겨 있다.
ADR 0001/0011의 additive-ablation invariant가 여기에 그대로 적용된다. baseline 행동은 보존되고, advanced surface는 옆에 들어가며, 실패 시 깔끔하게 baseline으로 fall back한다. 같은 한 가지 원칙이 세 ADR을 관통한다.
Span topology — debugging이 가능한 모양
trace를 attach하는 행위만으로는 안 된다. 어떻게 분해할지가 디버깅 가치를 결정한다.
| Span name | Cardinality | 핵심 attribute |
|---|---|---|
query_analysis |
2 (pre + post context resolution) | iteration ∈ {1, 2} |
context_resolution |
1 | — |
retrieve |
N (per retry attempt) | attempt_index, stage, top_k |
verify |
N (per retry attempt) | attempt_index, verifier_retry |
answer_generation |
1 | — |
synthesis |
0 or 1 (LLM 합성 켤 때만) | prompt_profile |
retry 루프가 분리된 span이라는 점이 핵심이다. 이슈 #69(verifier retry가 partial-topic grounding을 잘못 인정한 케이스)가 production에서 다시 발생한다고 가정하자. trace에서 attempt_index=1 retrieve와 attempt_index=2 retrieve의 doc_id가 다른지, 그리고 그 사이의 verify가 어떤 status로 끝났는지를 눈으로 보면 root cause가 즉시 잡힌다. 같은 정보가 stage_attempts에 요약돼 있긴 하지만, span sequence는 개별 attempt 단위로 zoom-in이 된다.
Cadence — 어디서 켜고 어디서 끄는가
| Surface | BIDMATE_TRACE_BACKEND |
이유 |
|---|---|---|
make smoke |
unset → none |
결정적, 외부 의존 0 |
pr-eval.yml (public CI) |
unset → none |
ADR 0005 boundary 보존 |
| Real-data eval | optional langfuse |
per-case trace는 reviewer 본인 host로 (ADR 0005 aggregate-only commit boundary 그대로) |
| Live demo (Fly.io) | langfuse |
Streamlit “View trace” 링크 노출, on-call 가능 상태 |
이 4개 surface가 동시에 작동하는 게 fail-closed contract의 효용이다. CI가 trace 없이 결정적으로 돈다는 사실과, production 데모가 동일 코드로 trace 풀어서 본다는 사실이 같은 코드 경로에서 보장된다.
다른 프로젝트에 적용 가능한 4가지
- Observability는 surface다, 모듈이 아니다. 파이프라인 코드와 trace 코드는 결합 지점이 한 줄이어야 한다 (이 프로젝트에선
_StageTimer(trace=)kwarg). 그 한 줄을 통해 vendor를 갈아 끼울 수 있어야 한다. - Default는 noop, 항상. “trace를 끄지 않으면 켜진다”는 안 된다. CI / smoke / 첫 사용자가 모두 외부 의존성 없이 동작해야 한다. 옵트인 비용은 환경변수 한 줄.
- 모든 boundary는 fail-closed. Optional dep 누락 → noop. credentials 누락 → noop. exporter 죽음 → noop. 그리고 왜 noop이 됐는지는
trace_unavailable_reason에 남긴다 — silently degrade도 안 된다. - 같은 registry 패턴을 여러 곳에 재사용한다. judge / synthesis / trace를 전부 같은
_BACKENDS = {...}모양으로 만들면, 새 contributor가 첫 backend를 추가할 때 다른 두 곳을 보고 패턴을 학습한다. 한 코드베이스에 한 가지 “pluggability”의 답.
다음 글에서
외부 baseline 라이브 비교 — LangChain RetrievalQA · LlamaIndex QueryEngine — 대칭 metric subset만 비교하고 비대칭 metric은 null로 명시한다는 ADR 0009 메서드론. “왜 자체 구축?” 질문에 정량 답변을 만드는 과정.
- 관련 ADR: 0013, 0011, 0006, 0001
- 구현:
rag_observability.py,rag_core.py:_StageTimer - Fail-closed regression test:
tests/test_observability_tracing.py - 운영 가이드:
docs/operations/observability.md