
SigNoz가 36,000개의 행을 스캔하고도 "데이터 없음"이라고 말한 이유
요약
실시간 음성 에이전트 구축 과정에서 OpenTelemetry 계측 시 발생한 8가지 디버깅 사례를 다룹니다. SigNoz와 ClickHouse 환경에서 데이터가 존재함에도 '데이터 없음'으로 표시되는 문제 등 실무적인 트러블슈팅 경험을 공유합니다.
핵심 포인트
- 실시간 음성 에이전트의 양방향 소켓 특성상 기존 OpenTelemetry 컨벤션 적용의 한계
- 음성 에이전트 성능 지표로서 '첫 오디오까지의 시간' 측정의 중요성
- 상태 머신을 통한 원시 신호 스트림의 OTLP 재구성 방법
- 로그나 예외 없이 발생하는 계측 시스템의 디버깅 사례 공유
나는 내 계측 (instrumentation)이 고장 났다고 확신하며 한 시간을 허비했다. 하지만 그렇지 않았다. 텔레메트리 (telemetry)는 도착하고 있었고, ClickHouse는 이를 읽고 있었으며, 모든 대시보드 패널은 36,291개의 행이 들어 있는 테이블을 두고도 여전히 **데이터 없음 (No Data)**이라고 표시하고 있었다.
이것은 지난주 실시간 음성 에이전트를 위한 OpenTelemetry 계측 (instrumentation)을 구축하면서 마주친 8가지 버그 중 하나였다. 그중 단 하나도 예외 (exception)를 발생시키지 않았다. 로그 (log)에 나타난 것도 없었다. 그중 6개는 코드가 이미 "작동"하고 있는 상태에서 발견했다.
이 포스트는 각 버그를 찾아내는 데 사용한 명령어와 함께 정리한 목록이다. 만약 당신이 음성, 비디오, 에이전트 루프와 같이 스트리밍되는 무엇인가를 계측 (instrumenting)하고 있다면, 아마도 이 중 최소 세 가지는 마주하게 될 것이다.
무엇을 만들고 있었는지 간략히 설명하자면
Gemini Live와 같은 음성 에이전트는 **지속적인 양방향 소켓 (persistent bidirectional socket)**을 유지한다. 오디오가 동시에 양방향으로 흐른다. 요청 (request)과 응답 (response)이 없기 때문에, 요청/응답 쌍을 중심으로 chat 스팬 (span)을 정의하는 OpenTelemetry GenAI 컨벤션 (conventions)에는 연결할 대상이 없다.
음성 에이전트에게 실제로 중요한 수치는 **첫 오디오까지의 시간 (time to first audio)**이다. 즉, 사람이 말을 멈춘 후 얼마나 오랫동안 침묵했는가 하는 것이다. 이 간격은 스팬 (span)들 _사이_에 존재하므로, 워터폴 (waterfall) 차트로는 그려지지 않는다.
그래서 나는 cadence를 만들었다. 이는 원시 신호 스트림 (raw signal stream)으로부터 턴 구조 (turn structure)를 재구성하여 일반적인 OTLP로 방출하는 상태 머신 (state machine)이다.
realtime.session 연결된 하나의 세션
├── realtime.turn 하나의 교환
│ ├── realtime.audio.user_utterance VAD 시작 → 종료
...

이것은 SigNoz에 나타난 실제 Gemini 세션이다. 여기에 도달하기까지 아래의 8가지 버그를 거쳐야 했다.
1. 콜렉터 (collector)가 실행 중이었지만, 아무것도 하지 않았다
나는 백엔드 (backend)와 그 MCP 서버를 한 번에 실행해 주는 Foundry를 사용하여 SigNoz를 설치했다:
curl -fsSL https://signoz.io/foundry.sh | bash
foundryctl cast -f deploy/casting.yaml
컨테이너(Containers)는 정상(healthy) 상태였고, 4318 포트도 열려 있었습니다. 하지만 익스포터(exporter)는 계속해서 RemoteDisconnected 로그를 남겼습니다. curl을 통해 확인해 보니, TCP 연결은 성공했지만 서버가 HTTP 응답 없이 연결을 끊어버렸습니다:
* Connected to localhost (127.0.0.1) port 4318
> POST /v1/traces HTTP/1.1
* Empty reply from server
실제 컬렉터(collector) 설정이 그 이유를 설명해 줍니다:
docker exec signoz-ingester-1 sh -lc \
"awk '/^service:/{f=1} f' /var/tmp/collector-config.yaml"
service:
pipelines:
traces:
...
모든 파이프라인(pipeline)이 nop(no-operation) 상태였습니다. 디스크 상의 설정 파일은 올바랐지만, 실행 중인 컬렉터는 해당 설정을 한 번도 전달받지 못한 상태였습니다. 그 이유는 SigNoz 서버 로그에 나와 있었습니다:
failed to find or create agent ... "cannot create agent without orgId"
SigNoz는 조직(organisation)이 존재하기 전까지는 컬렉터에 설정을 전달하지 않습니다. 저는 localhost:8080에서 첫 실행 시의 회원가입(signup)을 완료하지 않은 상태였습니다. 회원가입을 완료하기 전까지 OTLP 포트는 연결은 수락하지만 아무런 응답도 하지 않습니다.
회원가입을 완료하고 인제스터(ingester)를 재시작하면 파이프라인이 채워집니다.
2. 익스포터가 내 스팬(span)의 60%를 조용히 누락시켰다
나는 1,201개의 턴(turn)을 생성했습니다. ClickHouse에는 497개만 있었습니다.
SELECT name, count() FROM signoz_traces.distributed_signoz_index_v3
GROUP BY name ORDER BY count() DESC
어떠한 에러도 나타나지 않았습니다. OpenTelemetry의 BatchSpanProcessor는 기본적으로 **2,048-스팬 큐(queue)**를 사용하는데, 실시간 워크로드(workload)는 이를 즉시 초과해 버립니다. 각 턴은 4개에서 6개의 스팬을 생성하므로, 수백 개의 동시 턴만으로도 충분합니다. 큐가 가득 차면 스팬은 누락(drop)되지만, SDK는 에러를 발생시키지 않습니다.
BatchSpanProcessor(
OTLPSpanExporter(endpoint=f"{endpoint}/v1/traces", headers=headers),
max_queue_size=32_768, # 기본값 2048 — 스트리밍에는 너무 작음
...
다시 실행했습니다: 1,201개의 턴이 생성되었고, 1,201개가 모두 전달되었습니다.
3, 4, 5. 타임스탬프(timestamp)를 잘못 설정하는 세 가지 방법
저는 대시보드를 채우기 위해 기록된 세션(recorded sessions)을 재생(replay)하는데, 재생된 스팬(spans)의 길이는 0.06 ms로 나타난 반면 속성(attributes)상으로는 400 ms라고 주장하고 있었습니다. 스팬들이 이벤트가 발생한 시점이 아니라 벽시계(wall-clock) 기준의 "현재(now)" 시점으로 타임스탬프가 찍히고 있었기 때문에, 1초 동안 재생된 세션이 마이크로초(microsecond) 단위의 스팬을 생성하고 있었습니다.
OpenTelemetry API는 명시적인 타임스탬프(timestamps)를 허용합니다. 이를 사용하세요:
span = tracer.start_span(name, start_time=event.wall_ns, ...)
span.end(end_time=event.wall_ns)
그랬더니 모든 스팬이 1970년 근처에 배치되었습니다. 제가 벽시계(wall clock)로 time.monotonic()을 전달했기 때문입니다. 모노토닉(Monotonic)은 에포크(epoch) 이후가 아니라 부팅 이후의 시간입니다.
그리고 세션 스팬(session span)은 몇 시간의 지속 시간(duration)을 가진 것으로 나타났는데, 이는 제가 "현재(now)" 시점에 성급하게 스팬을 열고 재생된 과거 시점에 닫았기 때문입니다. 음수 지속 시간(Negative duration)이 부호 없는 정수(unsigned)로 래핑(wrapped)된 것입니다.
배울 만한 점은 이것입니다: 동일한 양에 대한 두 가지 관점을 비교하십시오. 스팬의 지속 시간(span duration)과 duration_ms 속성(attribute)은 일치해야 하므로, 다음을 확인하십시오:
SELECT attributes_string['realtime.prompt.version'] AS src,
round(avg(durationNano)/1e6) AS span_ms,
round(avg(attributes_number['realtime.turn.duration_ms'])) AS attr_ms
...
v16 4863 26135501
v17 5344 26095317
duration_ms 속성이 26,135,501(7시간)인 4.8초짜리 스팬이 있었습니다. 스팬은 이벤트 시계(event clock)로부터 타임스탬프가 찍힌 반면, 지속 시간은 time.monotonic()으로부터 계산되었습니다. 라이브 세션(live session)에서는 이 두 가지가 일치하므로 이 문제가 보이지 않습니다.
6. 누적(Cumulative) vs 델타(delta): 36,000개 행에 걸친 "데이터 없음(No Data)"
이것이 제목에서 언급한 내용입니다.
메트릭(Metrics)은 도착하고 있었습니다. signoz_metrics.distributed_samples_v4에 메트릭이 있었습니다. 모든 백분위수(percentile) 및 비율(rate) 쿼리가 아무것도 반환하지 않았고, 응답은 자신이 얼마나 많은 작업을 수행했는지 조용히 명시적으로 보여주었습니다:
"meta": { "rowsScanned": 36291, "bytesScanned": 495851 },
"data": { "results": [ { "queryName": "A", "aggregations": null } ] }
36,291개의 행을 스캔했습니다. 집계(Aggregated) 결과는 null이었습니다.
SigNoz는 카운터(Counter)와 히스토그램(Histogram)에 대해 델타 시간성 (Delta Temporality)을 기대합니다. 반면 OpenTelemetry Python SDK의 기본값은 누적 시간성 (Cumulative Temporality)입니다. 오류는 발생하지 않습니다. 데이터는 저장되지만, 모든 비율(Rate)과 백분위수(Percentile)가 아무것도 계산되지 않은 상태로 나옵니다.
from opentelemetry.sdk.metrics import Counter, Histogram
from opentelemetry.sdk.metrics.export import AggregationTemporality
...
딕셔너리 하나. 모든 패널이 채워졌습니다.

메트릭의 메타데이터를 자동으로 가져와서 자신이 무엇을 보유하고 있다고 판단하는지 알려주는 MCP 서버를 통해 확인해 볼 가치가 있습니다:
metricType: histogram (auto-fetched)
temporality: delta (auto-fetched)
7 그리고 8. 실제 API에서만 발견된 두 가지 버그
위의 모든 사항은 시뮬레이터를 대상으로 발견되었습니다. 그래서 저는 실제 Gemini Live API를 대상으로 테스트를 진행했습니다. 그리고 벤더(Vendor)와 접촉하는 유일한 구성 요소인 제 어댑터(Adapter)는 실제 메시지를 한 번도 처리해 본 적이 없었습니다.
두 가지 버그를 더 발견했습니다.
끼어들기(Barge-in)가 종료된 턴(Turn)에 대해 카운트되었습니다. 실제 세션에서는 22건의 끼어들기 이벤트가 보고되었으나, **중단된 턴(Interrupted turns)은 0%**였습니다. 이 수치들은 반드시 일치해야 합니다. "에이전트가 말하는 중"임을 표시하는 플래그(Flag)가 제 애플리케이션에서 전혀 방출하지 않은 재생(Playback) 이벤트에 의해서만 해제되었기 때문에, 첫 번째 턴 이후의 모든 턴이 중단된 것처럼 보였습니다. 그리고 끼어들기는 몇 초 전에 이미 내보내진(Exported) 스팬(Span)에 할당되었습니다.
수정 사항의 미묘한 차이: 턴이 *완료(Completes)*될 때 해당 플래그를 해제하되, 중단될 때는 해제하지 마십시오. 중단이 발생하면 에이전트의 오디오가 계속 재생되는 동안 사용자가 말을 끊는 즉시 턴이 종료됩니다. 그리고 그 남아있는 오디오가 바로 측정되고 있는 중첩(Overlap) 구간입니다.
그리고 지연 시간(Latency)은 제가 예상했던 것과는 전혀 달랐습니다.
| 소스 (Source) | 턴 (Turns) | 평균 TTFA (Mean TTFA) | p95 |
|---|---|---|---|
시뮬레이션된 v16 | 416 | 246 ms | 436 ms |
| ... | |||
![]() |
실제 Gemini Live는 제가 하루 종일 설계하며 기준으로 삼았던 베이스라인(Baseline)보다 3~4배 더 느립니다. 저의 350ms 목표는 대화 순서 교대(Turn-taking) 연구에서 도출된 것이며, 음성 기반의 턴에 대해서는 여전히 그것이 옳다고 생각합니다. 하지만 현재 이 모델을 통한 텍스트 기반의 턴에서는 도저히 달성할 수 없는 수치입니다.
저는 시뮬레이터를 결과에 맞춰 조용히 재조정하는 대신, 발견된 사실 그대로를 README에 남겨두었습니다. 당신의 가설과 일치하는 도구는 도구가 아닙니다.
제가 실제로 얻은 교훈
이 여덟 가지 실패 사례는 모두 침묵 속에서(silent) 일어났습니다. 애플리케이션은 내내 성공했다고 보고했습니다. 이것이 바로 프로덕션(Production) 환경에서 원하는 속성입니다. 텔레메트리(Telemetry)가 만족스럽지 않다고 해서 당신의 음성 에이전트가 죽어서는 안 되기 때문입니다. 그리고 바로 그 점 때문에 디버깅이 매우 고통스러워집니다.
그래서 저는 체인을 따라가며 끊어진 연결 고리를 찾아내는 사전 점검(Preflight check) 로직을 작성했습니다:
library ✓ cadence imports v0.1.0, schema 0.1.0
✓ turn state machine TTFA 400ms (expected 400ms)
signoz ✓ SigNoz set up v0.134.0
...
그중 두 가지 세부 사항이 다른 무엇보다 중요합니다. 첫째, 이 점검은 "HTTP 통신이 가능한가"와 별개로 TCP 도달 가능성(Reachability)을 확인합니다. 왜냐하면 nop 파이프라인은 연결을 수락한 뒤 바로 닫아버리기 때문입니다. 이 차이가 바로 버그 #1이었으며, 저에게 한 시간을 허비하게 만들었습니다. 둘째, 실제 상태 머신(State machine)을 통해 합성 턴(Synthetic turn)을 실행하고 TTFA가 2,400ms가 아닌 400ms인지 확인합니다. 침묵 속에서 잘못된 텔레메트리는 장애보다 더 나쁘기 때문입니다. 아무것도 깨지지 않은 채, 그저 잘못된 수치를 바탕으로 의사결정을 내리게 만들기 때문입니다.
이 글에서 한 가지만 기억하신다면 그것은 바로 동일한 수량에 대한 두 가지 관점을 비교하라는 것입니다.
Span duration (스팬 지속 시간)을 duration attribute (지속 시간 속성)와 비교해 보십시오. Barge-in events (끼어들기 이벤트)를 interrupted turns (중단된 턴)와 비교해 보십시오. 이 단 하나의 습관이 제가 발견한 8개의 버그 중 3개를 찾아냈으며, 그중 어떤 것도 다른 방식으로는 드러나지 않았을 것입니다.
_코드, realtime._ semantic conventions (시맨틱 컨벤션), 그리고 가져오기 가능한 SigNoz 대시보드:
github.com/preethamresearch/cadence
·
SigNoz hackathon의 Agents를 위해 제작되었습니다. 본인이 직접 작성하였으며, 코드 작성 및 이 포스트의 편집을 위해 AI 어시스턴트를 사용하였음을 제출물에 명시하였습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기