Skip to content

feat(tracing): add OTel observability layer for TAC voice calls - #99

Open
bsahajsinghani wants to merge 14 commits into
twilio:mainfrom
bsahajsinghani:bhoomi/otel-tracing-observability
Open

feat(tracing): add OTel observability layer for TAC voice calls#99
bsahajsinghani wants to merge 14 commits into
twilio:mainfrom
bsahajsinghani:bhoomi/otel-tracing-observability

Conversation

@bsahajsinghani

@bsahajsinghani bsahajsinghani commented Aug 5, 2026

Copy link
Copy Markdown

Summary

  • Moves OTel packages to an optional tac[tracing] extra — no longer a hard dependency for all TAC users
  • Auto-initializes tracing in TAC.__init__ when OTEL_ENABLED=true — zero manual setup required by the developer
  • Changes default OTEL_ENDPOINT from a Twilio-internal URL to http://localhost:4318 (standard OTel collector port, works out of the box with Jaeger, Grafana Tempo, Honeycomb, Datadog)
  • Adds graceful ImportError handling in setup_tracing() with an actionable warning when packages are not installed
  • Adds tests/test_tracing.py: 19 tests covering no-op behaviour, start/end call lifecycle, all span types, turn counter, and traceparent injection

Span hierarchy for a voice call

call  [root — full call duration]
├── call.co_init              CO polling latency at call start
├── call.profile_lookup       phone → profile_id resolution
├── call.first_prompt_wait    how long first prompt waited for init_task
└── turn  [one per user utterance]
    ├── memory.recall
    │   ├── memory.profile_lookup
    │   ├── memory.profile_fetch
    │   └── memory.recall_api    (vector search only)
    └── llm.completion
        ├── llm.prompt_build
        └── llm.response_stream
            └── event: first_token_sent   (LLM TTFT marker)

Why this matters — latency data

Instrumented via this tracing layer across 4 experiments on a SageMaker-hosted Qwen2.5-1.5B voice agent:

Experiment TTFA app_lat co_init profile_lookup first_prompt_wait
Baseline 1322ms 1104ms 940ms 753ms
Early co_init (9h) 784ms 549ms 3607ms 528ms
Parallel init (9i) 1054ms 842ms 3316ms 390ms
Pre-fetch (9j) 1274ms 1062ms 3479ms 467ms 0ms

first_prompt_wait=0ms across all 9j runs confirms that pre-fetching observations inside the init task is safe — CO poll always completes before the user finishes speaking.

Usage

pip install tac[tracing]
OTEL_ENABLED=true
OTEL_ENDPOINT=http://localhost:4318   # or your Honeycomb/Datadog/Grafana endpoint
OTEL_SERVICE_NAME=my-voice-agent

That's it — no code changes needed. Tracing starts automatically when TAC is instantiated.

Test plan

  • uv run pytest tests/test_tracing.py — 19 tests pass
  • uv run pytest — 711 tests pass, no regressions

bsahajsinghani and others added 8 commits June 25, 2026 17:06
- Fix create_observation() payload format: wrap in {"observations": [...]}
  so Memora API accepts the request (was sending flat dict → silent 400)
- Inject Memora memory into agent instructions via MemoryPromptBuilder.compose()
  so the LLM actually uses observations/summaries from past calls
- Enable memory_mode="always" in voice_streaming.py example
- Wire up CI webhook: if CONVERSATION_INTELLIGENCE_CONFIGURATION_ID is set,
  expose /ci-webhook route for near real-time custom operator ingestion
- Auto-close conversation via CO API on WebSocket disconnect so Memora
  extraction triggers immediately instead of waiting for inactive timeout
- Add CI/memory section to getting_started/README.md explaining standard
  vs alternate write paths, latency tips, and links to lifecycle docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
**STT model selection:**
- Add `transcription_provider` and `speech_model` fields to VoiceChannelConfig,
  TwiMLOptions, and VoiceChannel so callers can pass e.g.
  `{"transcription_provider": "deepgram", "speech_model": "flux"}` at construction
- Wire both fields through twiml.py → ConversationRelay attributes
  (`transcriptionProvider`, `speechModel`)
- Deepgram Flux is turn-aware: waits for a complete utterance before emitting
  a transcript, eliminating spurious mid-utterance STT chunks and the redundant
  Recall+LLM calls they caused (~6:1 chunk:turn → 1:1 with Flux)
- Document all supported Deepgram and Google models in getting_started/README.md
  with a note about the error-64101 pitfall for invalid model names

**OTel tracing:**
- Add src/tac/tracing.py: lightweight OTel setup module controlled by
  OTEL_ENABLED / OTEL_ENDPOINT / OTEL_SERVICE_NAME env vars
- Provides start_call(), end_call(), turn_span(), memory_span(), llm_span(),
  inject_traceparent() context managers used in channel.py
- Wire traceparent header propagation into BaseAPIClient so Recall HTTP calls
  carry the active trace context (enables Memora correlation on Grafana)
- Export `tracing` from tac.__init__ so examples can call tracing.setup_tracing()
- Add LATENCY_OBSERVABILITY_PLAN.md: phased plan for full end-to-end latency
  measurement (Jaeger local → missing spans → Voice Insights → VAD TTFA)

**STT chunk diagnostics (debug-only):**
- [STT] / [STT TURN N] / [STT SUMMARY] print statements in channel.py to
  confirm chunk:turn ratio during benchmarking; not intended for production

Note: This is a personal benchmarking branch — no review needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fills in previously untracked timing gaps in the voice call lifecycle:

- call.co_init: measures time TAC spends waiting for Conversation Orchestrator
  to create the conversation after ConversationRelay connects. This polling
  phase can take several hundred milliseconds depending on CO load and was
  previously invisible.

- call.profile_lookup: measures the participant fetch and customer address
  resolution that happens immediately after conversation init. Covers the
  list_participants() HTTP call and customer profile ID resolution.

- record_first_token(): adds a first_token_sent point-in-time event to the
  llm.completion span the moment the first streaming token is written to the
  WebSocket. This splits the LLM span into two readable phases: time waiting
  for the first token (TTFT) and time streaming the full response.

Both co_init and profile_lookup are parented to the call root span rather
than the turn span, since they only occur once at call start.

Note: personal benchmarking branch — no review needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Records two actionable findings from Phase 1-2 Jaeger traces:
- CO init + profile lookup accounts for ~1s at call start
- memory.recall TAC-side is 4-7x Memora server-side; switching memory_mode
  to "once" would save ~700ms/turn from turn 2 onwards

Note: personal benchmarking branch — no review needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Notes that the memory.recall overhead is not a region mismatch — TAC and
Memora are co-located in us-east-1 (intra-AZ). The ~280ms gap per recall
is TLS connection setup paid fresh on every call. Connection pooling +
memory_mode "once" together would reduce per-turn recall from ~700ms to
near zero after turn 1.

Note: personal benchmarking branch — no review needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rgets

Added industry-standard latency thresholds, full experiment configuration
table aligned with the intern project proposal, and additional optimization
ideas to explore beyond the core experiments.

Note: personal benchmarking branch — no review needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… coverage

Breaks down the two previously opaque spans into individually measurable steps:

memory.recall now contains:
  - memory.profile_lookup — lookup_profile() HTTP call
  - memory.profile_fetch  — get_profile() HTTP call to fetch traits
  - memory.recall_api     — the actual Memora vector search call

llm.completion now contains:
  - llm.prompt_build      — MemoryPromptBuilder + message deepcopy
  - llm.response_stream   — WebSocket streaming loop, first to last token sent

Note: personal benchmarking branch — no review needed.
- Move OTel packages to optional tac[tracing] extra — no longer a hard
  dependency for all TAC users
- Auto-initialize tracing in TAC.__init__ when OTEL_ENABLED=true — zero
  manual setup required
- Change default OTEL_ENDPOINT from Twilio-internal URL to
  http://localhost:4318 (standard OTel collector port, works with any
  backend — Jaeger, Grafana Tempo, Honeycomb, Datadog)
- Graceful ImportError handling in setup_tracing() with actionable warning
  when packages not installed
- Add test_tracing.py: 19 tests covering no-op behaviour, start/end call
  lifecycle, all span types, turn counter, and traceparent injection

Span hierarchy for a voice call:
  call → call.co_init, call.profile_lookup, call.first_prompt_wait
       → turn → memory.recall → memory.recall_api, memory.profile_lookup
              → llm.completion → llm.prompt_build, llm.response_stream
                                 (event: first_token_sent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bsahajsinghani and others added 6 commits August 5, 2026 14:24
- Fix isort order in tac.py and tracing.py
- Replace typing.Iterator with collections.abc.Iterator (UP035)
- Remove unused NonRecordingSpan import
- Remove unused os/MagicMock imports in test_tracing.py
- Update module docstring to reflect new default endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wraps tac.list_observations() with an OTel child span so latency of the
observations fetch is visible in Jaeger alongside co_init and profile_lookup.
Adds record_obs_fetch_count() to emit an observations.fetched event with
the actual count loaded. Adds two test coverage cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use call_sid from session metadata in _cleanup_connection so end_call
  always uses the same key that start_call was registered with (fixes
  orchestrated mode where conv_id != call_sid)
- Close span directly in finally block when conv_id is None (user hangs
  up before first prompt) to prevent span leak
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant