Add tests for TracingContext and TurnTraceObserver
Cover pipeline-scoped tracing context lifecycle, span hierarchy, conversation/turn context management, and concurrent pipeline isolation.
This commit is contained in:
1
.github/workflows/coverage.yaml
vendored
1
.github/workflows/coverage.yaml
vendored
@@ -41,6 +41,7 @@ jobs:
|
||||
--extra livekit \
|
||||
--extra local-smart-turn-v3 \
|
||||
--extra piper \
|
||||
--extra tracing \
|
||||
--extra websocket
|
||||
|
||||
- name: Run tests with coverage
|
||||
|
||||
1
.github/workflows/tests.yaml
vendored
1
.github/workflows/tests.yaml
vendored
@@ -45,6 +45,7 @@ jobs:
|
||||
--extra livekit \
|
||||
--extra local-smart-turn-v3 \
|
||||
--extra piper \
|
||||
--extra tracing \
|
||||
--extra websocket
|
||||
|
||||
- name: Test with pytest
|
||||
|
||||
127
tests/test_tracing_context.py
Normal file
127
tests/test_tracing_context.py
Normal file
@@ -0,0 +1,127 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import unittest
|
||||
|
||||
try:
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
HAS_OPENTELEMETRY = True
|
||||
except ImportError:
|
||||
HAS_OPENTELEMETRY = False
|
||||
|
||||
from pipecat.utils.tracing.tracing_context import TracingContext
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_OPENTELEMETRY, "opentelemetry not installed")
|
||||
class TestTracingContext(unittest.TestCase):
|
||||
"""Tests for TracingContext."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up a tracer provider for generating span contexts."""
|
||||
cls._provider = TracerProvider()
|
||||
cls._tracer = cls._provider.get_tracer("test")
|
||||
|
||||
def test_initial_state_is_empty(self):
|
||||
"""Test that a new TracingContext starts with no context set."""
|
||||
ctx = TracingContext()
|
||||
self.assertIsNone(ctx.get_conversation_context())
|
||||
self.assertIsNone(ctx.get_turn_context())
|
||||
self.assertIsNone(ctx.conversation_id)
|
||||
|
||||
def test_set_and_get_conversation_context(self):
|
||||
"""Test setting and retrieving conversation context."""
|
||||
ctx = TracingContext()
|
||||
span = self._tracer.start_span("conv")
|
||||
span_context = span.get_span_context()
|
||||
|
||||
ctx.set_conversation_context(span_context, "conv-123")
|
||||
|
||||
self.assertIsNotNone(ctx.get_conversation_context())
|
||||
self.assertEqual(ctx.conversation_id, "conv-123")
|
||||
span.end()
|
||||
|
||||
def test_clear_conversation_context(self):
|
||||
"""Test clearing conversation context by passing None."""
|
||||
ctx = TracingContext()
|
||||
span = self._tracer.start_span("conv")
|
||||
|
||||
ctx.set_conversation_context(span.get_span_context(), "conv-123")
|
||||
self.assertIsNotNone(ctx.get_conversation_context())
|
||||
|
||||
ctx.set_conversation_context(None)
|
||||
self.assertIsNone(ctx.get_conversation_context())
|
||||
self.assertIsNone(ctx.conversation_id)
|
||||
span.end()
|
||||
|
||||
def test_set_and_get_turn_context(self):
|
||||
"""Test setting and retrieving turn context."""
|
||||
ctx = TracingContext()
|
||||
span = self._tracer.start_span("turn")
|
||||
span_context = span.get_span_context()
|
||||
|
||||
ctx.set_turn_context(span_context)
|
||||
|
||||
self.assertIsNotNone(ctx.get_turn_context())
|
||||
span.end()
|
||||
|
||||
def test_clear_turn_context(self):
|
||||
"""Test clearing turn context by passing None."""
|
||||
ctx = TracingContext()
|
||||
span = self._tracer.start_span("turn")
|
||||
|
||||
ctx.set_turn_context(span.get_span_context())
|
||||
self.assertIsNotNone(ctx.get_turn_context())
|
||||
|
||||
ctx.set_turn_context(None)
|
||||
self.assertIsNone(ctx.get_turn_context())
|
||||
span.end()
|
||||
|
||||
def test_generate_conversation_id(self):
|
||||
"""Test that generated conversation IDs are unique UUIDs."""
|
||||
id1 = TracingContext.generate_conversation_id()
|
||||
id2 = TracingContext.generate_conversation_id()
|
||||
self.assertIsInstance(id1, str)
|
||||
self.assertNotEqual(id1, id2)
|
||||
|
||||
def test_instances_are_isolated(self):
|
||||
"""Test that two TracingContext instances do not share state."""
|
||||
ctx_a = TracingContext()
|
||||
ctx_b = TracingContext()
|
||||
|
||||
span = self._tracer.start_span("turn")
|
||||
|
||||
ctx_a.set_turn_context(span.get_span_context())
|
||||
ctx_a.set_conversation_context(span.get_span_context(), "conv-a")
|
||||
|
||||
# ctx_b should still be empty
|
||||
self.assertIsNone(ctx_b.get_turn_context())
|
||||
self.assertIsNone(ctx_b.get_conversation_context())
|
||||
self.assertIsNone(ctx_b.conversation_id)
|
||||
span.end()
|
||||
|
||||
def test_conversation_and_turn_are_independent(self):
|
||||
"""Test that clearing turn context does not affect conversation context."""
|
||||
ctx = TracingContext()
|
||||
conv_span = self._tracer.start_span("conv")
|
||||
turn_span = self._tracer.start_span("turn")
|
||||
|
||||
ctx.set_conversation_context(conv_span.get_span_context(), "conv-1")
|
||||
ctx.set_turn_context(turn_span.get_span_context())
|
||||
|
||||
# Clear turn but conversation should remain
|
||||
ctx.set_turn_context(None)
|
||||
self.assertIsNone(ctx.get_turn_context())
|
||||
self.assertIsNotNone(ctx.get_conversation_context())
|
||||
self.assertEqual(ctx.conversation_id, "conv-1")
|
||||
|
||||
conv_span.end()
|
||||
turn_span.end()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
505
tests/test_turn_trace_observer.py
Normal file
505
tests/test_turn_trace_observer.py
Normal file
@@ -0,0 +1,505 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
try:
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
|
||||
|
||||
HAS_OPENTELEMETRY = True
|
||||
except ImportError:
|
||||
HAS_OPENTELEMETRY = False
|
||||
|
||||
from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
)
|
||||
from pipecat.observers.turn_tracking_observer import TurnTrackingObserver
|
||||
from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver
|
||||
from pipecat.processors.filters.identity_filter import IdentityFilter
|
||||
from pipecat.tests.utils import SleepFrame, run_test
|
||||
from pipecat.utils.tracing.tracing_context import TracingContext
|
||||
from pipecat.utils.tracing.turn_trace_observer import TurnTraceObserver
|
||||
|
||||
if HAS_OPENTELEMETRY:
|
||||
|
||||
class _InMemorySpanExporter(SpanExporter):
|
||||
"""Simple in-memory span exporter for testing."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the exporter."""
|
||||
self._spans = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def export(self, spans):
|
||||
"""Export spans to memory."""
|
||||
with self._lock:
|
||||
self._spans.extend(spans)
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
def get_finished_spans(self):
|
||||
"""Return collected spans."""
|
||||
with self._lock:
|
||||
return list(self._spans)
|
||||
|
||||
def clear(self):
|
||||
"""Clear collected spans."""
|
||||
with self._lock:
|
||||
self._spans.clear()
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_OPENTELEMETRY, "opentelemetry not installed")
|
||||
class TestTurnTraceObserver(unittest.IsolatedAsyncioTestCase):
|
||||
"""Tests for TurnTraceObserver."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up a fresh provider and exporter for each test.
|
||||
|
||||
We create a dedicated TracerProvider per test and inject its tracer
|
||||
directly into the observer, avoiding the global provider singleton.
|
||||
"""
|
||||
self._exporter = _InMemorySpanExporter()
|
||||
self._provider = TracerProvider()
|
||||
self._provider.add_span_processor(SimpleSpanProcessor(self._exporter))
|
||||
self._tracer = self._provider.get_tracer("pipecat.turn")
|
||||
|
||||
def tearDown(self):
|
||||
"""Shut down the provider to flush spans."""
|
||||
self._provider.shutdown()
|
||||
|
||||
def _create_observers(self, conversation_id=None, tracing_context=None):
|
||||
"""Create a standard set of turn/trace observers.
|
||||
|
||||
Args:
|
||||
conversation_id: Optional conversation ID.
|
||||
tracing_context: Optional TracingContext instance.
|
||||
|
||||
Returns:
|
||||
Tuple of (turn_tracker, latency_tracker, trace_observer, tracing_context).
|
||||
"""
|
||||
tracing_context = tracing_context or TracingContext()
|
||||
turn_tracker = TurnTrackingObserver(turn_end_timeout_secs=0.2)
|
||||
latency_tracker = UserBotLatencyObserver()
|
||||
trace_observer = TurnTraceObserver(
|
||||
turn_tracker,
|
||||
latency_tracker=latency_tracker,
|
||||
conversation_id=conversation_id,
|
||||
tracing_context=tracing_context,
|
||||
)
|
||||
# Inject the test tracer so spans go to our in-memory exporter
|
||||
trace_observer._tracer = self._tracer
|
||||
return turn_tracker, latency_tracker, trace_observer, tracing_context
|
||||
|
||||
def _all_observers(self, trace_observer):
|
||||
"""Return the list of observers needed for run_test."""
|
||||
return [trace_observer._turn_tracker, trace_observer._latency_tracker, trace_observer]
|
||||
|
||||
def _get_spans_by_name(self, name):
|
||||
"""Return finished spans with the given name."""
|
||||
return [s for s in self._exporter.get_finished_spans() if s.name == name]
|
||||
|
||||
async def test_conversation_span_created_on_start_frame(self):
|
||||
"""Test that a conversation span is created when StartFrame is observed."""
|
||||
_, _, trace_observer, _ = self._create_observers(conversation_id="test-conv")
|
||||
processor = IdentityFilter()
|
||||
|
||||
frames_to_send = [
|
||||
UserStartedSpeakingFrame(),
|
||||
UserStoppedSpeakingFrame(),
|
||||
BotStartedSpeakingFrame(),
|
||||
BotStoppedSpeakingFrame(),
|
||||
SleepFrame(sleep=0.4),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
observers=self._all_observers(trace_observer),
|
||||
)
|
||||
|
||||
# End conversation to flush the conversation span (normally done by PipelineTask._cleanup)
|
||||
trace_observer.end_conversation_tracing()
|
||||
|
||||
conv_spans = self._get_spans_by_name("conversation")
|
||||
self.assertEqual(len(conv_spans), 1)
|
||||
self.assertEqual(conv_spans[0].attributes["conversation.id"], "test-conv")
|
||||
self.assertEqual(conv_spans[0].attributes["conversation.type"], "voice")
|
||||
|
||||
async def test_turn_spans_created_for_each_turn(self):
|
||||
"""Test that a turn span is created for each conversation turn."""
|
||||
_, _, trace_observer, _ = self._create_observers()
|
||||
processor = IdentityFilter()
|
||||
|
||||
frames_to_send = [
|
||||
# Turn 1
|
||||
UserStartedSpeakingFrame(),
|
||||
UserStoppedSpeakingFrame(),
|
||||
BotStartedSpeakingFrame(),
|
||||
BotStoppedSpeakingFrame(),
|
||||
SleepFrame(sleep=0.05),
|
||||
# Turn 2
|
||||
UserStartedSpeakingFrame(),
|
||||
UserStoppedSpeakingFrame(),
|
||||
BotStartedSpeakingFrame(),
|
||||
BotStoppedSpeakingFrame(),
|
||||
SleepFrame(sleep=0.4),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
observers=self._all_observers(trace_observer),
|
||||
)
|
||||
|
||||
turn_spans = self._get_spans_by_name("turn")
|
||||
self.assertEqual(len(turn_spans), 2)
|
||||
turn_numbers = {s.attributes["turn.number"] for s in turn_spans}
|
||||
self.assertEqual(turn_numbers, {1, 2})
|
||||
|
||||
async def test_turn_spans_are_children_of_conversation(self):
|
||||
"""Test that turn spans are parented under the conversation span."""
|
||||
_, _, trace_observer, _ = self._create_observers()
|
||||
processor = IdentityFilter()
|
||||
|
||||
frames_to_send = [
|
||||
UserStartedSpeakingFrame(),
|
||||
UserStoppedSpeakingFrame(),
|
||||
BotStartedSpeakingFrame(),
|
||||
BotStoppedSpeakingFrame(),
|
||||
SleepFrame(sleep=0.4),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
observers=self._all_observers(trace_observer),
|
||||
)
|
||||
|
||||
# End conversation to flush the conversation span
|
||||
trace_observer.end_conversation_tracing()
|
||||
|
||||
conv_spans = self._get_spans_by_name("conversation")
|
||||
turn_spans = self._get_spans_by_name("turn")
|
||||
self.assertEqual(len(conv_spans), 1)
|
||||
self.assertEqual(len(turn_spans), 1)
|
||||
|
||||
# Turn span's parent should be the conversation span
|
||||
conv_span_id = conv_spans[0].context.span_id
|
||||
turn_parent_id = turn_spans[0].parent.span_id
|
||||
self.assertEqual(turn_parent_id, conv_span_id)
|
||||
|
||||
async def test_interrupted_turn_marked(self):
|
||||
"""Test that an interrupted turn span has was_interrupted=True."""
|
||||
_, _, trace_observer, _ = self._create_observers()
|
||||
processor = IdentityFilter()
|
||||
|
||||
frames_to_send = [
|
||||
UserStartedSpeakingFrame(),
|
||||
UserStoppedSpeakingFrame(),
|
||||
BotStartedSpeakingFrame(),
|
||||
# User interrupts
|
||||
UserStartedSpeakingFrame(),
|
||||
SleepFrame(sleep=0.4),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
BotStartedSpeakingFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
observers=self._all_observers(trace_observer),
|
||||
)
|
||||
|
||||
# End conversation to flush remaining spans
|
||||
trace_observer.end_conversation_tracing()
|
||||
|
||||
turn_spans = self._get_spans_by_name("turn")
|
||||
self.assertGreaterEqual(len(turn_spans), 1)
|
||||
# First turn should be interrupted
|
||||
interrupted_turns = [s for s in turn_spans if s.attributes.get("turn.was_interrupted")]
|
||||
self.assertGreaterEqual(len(interrupted_turns), 1)
|
||||
|
||||
async def test_tracing_context_updated_during_turn(self):
|
||||
"""Test that TracingContext is populated during a turn and cleared after."""
|
||||
tracing_ctx = TracingContext()
|
||||
_, _, trace_observer, _ = self._create_observers(tracing_context=tracing_ctx)
|
||||
processor = IdentityFilter()
|
||||
|
||||
frames_to_send = [
|
||||
UserStartedSpeakingFrame(),
|
||||
UserStoppedSpeakingFrame(),
|
||||
BotStartedSpeakingFrame(),
|
||||
BotStoppedSpeakingFrame(),
|
||||
SleepFrame(sleep=0.4),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
observers=self._all_observers(trace_observer),
|
||||
)
|
||||
|
||||
# After the turn ends, turn context should be cleared
|
||||
self.assertIsNone(tracing_ctx.get_turn_context())
|
||||
|
||||
async def test_tracing_context_cleared_after_conversation_end(self):
|
||||
"""Test that TracingContext is cleared when conversation tracing ends."""
|
||||
tracing_ctx = TracingContext()
|
||||
_, _, trace_observer, _ = self._create_observers(tracing_context=tracing_ctx)
|
||||
processor = IdentityFilter()
|
||||
|
||||
frames_to_send = [
|
||||
UserStartedSpeakingFrame(),
|
||||
UserStoppedSpeakingFrame(),
|
||||
BotStartedSpeakingFrame(),
|
||||
BotStoppedSpeakingFrame(),
|
||||
SleepFrame(sleep=0.4),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
observers=self._all_observers(trace_observer),
|
||||
)
|
||||
|
||||
# Manually end conversation tracing (as PipelineTask._cleanup does)
|
||||
trace_observer.end_conversation_tracing()
|
||||
|
||||
self.assertIsNone(tracing_ctx.get_conversation_context())
|
||||
self.assertIsNone(tracing_ctx.get_turn_context())
|
||||
self.assertIsNone(tracing_ctx.conversation_id)
|
||||
|
||||
async def test_additional_span_attributes(self):
|
||||
"""Test that additional span attributes are added to the conversation span."""
|
||||
extra_attrs = {"deployment.id": "abc-123", "customer.tier": "premium"}
|
||||
tracing_ctx = TracingContext()
|
||||
turn_tracker = TurnTrackingObserver(turn_end_timeout_secs=0.2)
|
||||
latency_tracker = UserBotLatencyObserver()
|
||||
trace_observer = TurnTraceObserver(
|
||||
turn_tracker,
|
||||
latency_tracker=latency_tracker,
|
||||
additional_span_attributes=extra_attrs,
|
||||
tracing_context=tracing_ctx,
|
||||
)
|
||||
trace_observer._tracer = self._tracer
|
||||
processor = IdentityFilter()
|
||||
|
||||
frames_to_send = [
|
||||
UserStartedSpeakingFrame(),
|
||||
UserStoppedSpeakingFrame(),
|
||||
BotStartedSpeakingFrame(),
|
||||
BotStoppedSpeakingFrame(),
|
||||
SleepFrame(sleep=0.4),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
observers=[turn_tracker, latency_tracker, trace_observer],
|
||||
)
|
||||
|
||||
# End conversation to flush the conversation span
|
||||
trace_observer.end_conversation_tracing()
|
||||
|
||||
conv_spans = self._get_spans_by_name("conversation")
|
||||
self.assertEqual(len(conv_spans), 1)
|
||||
self.assertEqual(conv_spans[0].attributes["deployment.id"], "abc-123")
|
||||
self.assertEqual(conv_spans[0].attributes["customer.tier"], "premium")
|
||||
|
||||
async def test_concurrent_pipelines_are_isolated(self):
|
||||
"""Test that two pipelines with separate TracingContexts don't interfere."""
|
||||
tracing_ctx_a = TracingContext()
|
||||
tracing_ctx_b = TracingContext()
|
||||
|
||||
_, _, trace_observer_a, _ = self._create_observers(
|
||||
conversation_id="conv-a", tracing_context=tracing_ctx_a
|
||||
)
|
||||
_, _, trace_observer_b, _ = self._create_observers(
|
||||
conversation_id="conv-b", tracing_context=tracing_ctx_b
|
||||
)
|
||||
|
||||
processor_a = IdentityFilter()
|
||||
processor_b = IdentityFilter()
|
||||
|
||||
frames = [
|
||||
UserStartedSpeakingFrame(),
|
||||
UserStoppedSpeakingFrame(),
|
||||
BotStartedSpeakingFrame(),
|
||||
BotStoppedSpeakingFrame(),
|
||||
SleepFrame(sleep=0.4),
|
||||
]
|
||||
|
||||
expected = [
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
]
|
||||
|
||||
# Run both pipelines concurrently
|
||||
await asyncio.gather(
|
||||
run_test(
|
||||
processor_a,
|
||||
frames_to_send=frames,
|
||||
expected_down_frames=expected,
|
||||
observers=self._all_observers(trace_observer_a),
|
||||
),
|
||||
run_test(
|
||||
processor_b,
|
||||
frames_to_send=frames,
|
||||
expected_down_frames=expected,
|
||||
observers=self._all_observers(trace_observer_b),
|
||||
),
|
||||
)
|
||||
|
||||
# End both conversations to flush spans
|
||||
trace_observer_a.end_conversation_tracing()
|
||||
trace_observer_b.end_conversation_tracing()
|
||||
|
||||
# Each TracingContext should have its own conversation ID
|
||||
conv_spans = self._get_spans_by_name("conversation")
|
||||
conv_ids = {s.attributes["conversation.id"] for s in conv_spans}
|
||||
self.assertEqual(conv_ids, {"conv-a", "conv-b"})
|
||||
|
||||
# Turn spans should be children of their own conversation span, not cross-linked
|
||||
turn_spans = self._get_spans_by_name("turn")
|
||||
conv_span_map = {s.context.span_id: s.attributes["conversation.id"] for s in conv_spans}
|
||||
for turn_span in turn_spans:
|
||||
parent_id = turn_span.parent.span_id
|
||||
turn_conv_id = turn_span.attributes["conversation.id"]
|
||||
parent_conv_id = conv_span_map[parent_id]
|
||||
self.assertEqual(
|
||||
turn_conv_id,
|
||||
parent_conv_id,
|
||||
f"Turn span for {turn_conv_id} parented under {parent_conv_id}",
|
||||
)
|
||||
|
||||
async def test_end_conversation_closes_active_turn(self):
|
||||
"""Test that end_conversation_tracing closes any active turn span."""
|
||||
_, _, trace_observer, _ = self._create_observers()
|
||||
|
||||
# Manually start conversation and a turn
|
||||
trace_observer.start_conversation_tracing("conv-end-test")
|
||||
await trace_observer._handle_turn_started(1)
|
||||
|
||||
self.assertIsNotNone(trace_observer._current_span)
|
||||
self.assertIsNotNone(trace_observer._conversation_span)
|
||||
|
||||
# End conversation — should close both turn and conversation
|
||||
trace_observer.end_conversation_tracing()
|
||||
|
||||
self.assertIsNone(trace_observer._current_span)
|
||||
self.assertIsNone(trace_observer._conversation_span)
|
||||
|
||||
# Check span attributes
|
||||
turn_spans = self._get_spans_by_name("turn")
|
||||
self.assertEqual(len(turn_spans), 1)
|
||||
self.assertTrue(turn_spans[0].attributes["turn.was_interrupted"])
|
||||
self.assertTrue(turn_spans[0].attributes["turn.ended_by_conversation_end"])
|
||||
|
||||
async def test_conversation_id_auto_generated(self):
|
||||
"""Test that a conversation ID is auto-generated when none is provided."""
|
||||
_, _, trace_observer, _ = self._create_observers(conversation_id=None)
|
||||
processor = IdentityFilter()
|
||||
|
||||
frames_to_send = [
|
||||
UserStartedSpeakingFrame(),
|
||||
UserStoppedSpeakingFrame(),
|
||||
BotStartedSpeakingFrame(),
|
||||
BotStoppedSpeakingFrame(),
|
||||
SleepFrame(sleep=0.4),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
observers=self._all_observers(trace_observer),
|
||||
)
|
||||
|
||||
# End conversation to flush the conversation span
|
||||
trace_observer.end_conversation_tracing()
|
||||
|
||||
conv_spans = self._get_spans_by_name("conversation")
|
||||
self.assertEqual(len(conv_spans), 1)
|
||||
# Should have an auto-generated UUID as conversation.id
|
||||
conv_id = conv_spans[0].attributes["conversation.id"]
|
||||
self.assertIsNotNone(conv_id)
|
||||
self.assertGreater(len(conv_id), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user