honcho/tests/telemetry/conftest.py

620 lines
20 KiB
Python

# pyright: reportUnknownMemberType=false
"""Fixtures for telemetry unit tests.
This module provides:
- Sample event fixtures for all telemetry event types
- Mock settings fixtures for controlling telemetry configuration
- Mock HTTP client fixtures for testing the emitter without network calls
"""
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.telemetry.events.agent import (
AgentIterationEvent,
AgentToolConclusionsCreatedEvent,
AgentToolConclusionsDeletedEvent,
AgentToolPeerCardUpdatedEvent,
AgentToolSummaryCreatedEvent,
)
from src.telemetry.events.api import (
FileUploadedEvent,
GetContextEvent,
MessageCreatedEvent,
)
from src.telemetry.events.base import BaseEvent
from src.telemetry.events.deletion import DeletionCompletedEvent
from src.telemetry.events.dialectic import DialecticCompletedEvent
from src.telemetry.events.dream import DreamRunEvent, DreamSpecialistEvent
from src.telemetry.events.llm import CallPurpose, LLMCallCompletedEvent
from src.telemetry.events.reconciliation import (
CleanupStaleItemsCompletedEvent,
SyncVectorsCompletedEvent,
)
from src.telemetry.events.representation import RepresentationCompletedEvent
# =============================================================================
# Global trace-state isolation
# =============================================================================
@pytest.fixture(autouse=True)
def _isolate_trace_globals(): # pyright: ignore[reportUnusedFunction]
"""Snapshot/restore process-global trace state around every telemetry test.
`initialize_telemetry_events()` (exercised in test_emit_function) registers a
real ``TraceExporter`` into ``capture._EXPORTERS`` and starts a real trace
emitter (``emitter._trace_emitter``). Without cleanup that state bleeds into
the trace-exporter tests, which then fail — and because xdist schedules tests
across workers nondeterministically, the failure looks flaky (a different
trace test fails each run depending on who shared its worker).
Snapshotting these globals (and resetting the per-run dedup) before/after
each test makes the trace tests hermetic regardless of neighbor ordering.
"""
from src.llm import capture
from src.telemetry import emitter as emitter_mod
from src.telemetry import langfuse_session, trace_session
saved_exporters = list(capture._EXPORTERS) # pyright: ignore[reportPrivateUsage]
saved_trace_emitter = emitter_mod._trace_emitter # pyright: ignore[reportPrivateUsage]
trace_session.reset()
langfuse_session.reset()
try:
yield
finally:
capture._EXPORTERS[:] = saved_exporters # pyright: ignore[reportPrivateUsage]
emitter_mod._trace_emitter = saved_trace_emitter # pyright: ignore[reportPrivateUsage]
trace_session.reset()
langfuse_session.reset()
# =============================================================================
# Fixed timestamp for deterministic tests
# =============================================================================
@pytest.fixture
def fixed_timestamp() -> datetime:
"""Return a fixed timestamp for deterministic event ID generation."""
return datetime(2024, 1, 15, 12, 0, 0, tzinfo=UTC)
# =============================================================================
# Sample event fixtures for all event types
# =============================================================================
@pytest.fixture
def sample_representation_event(
fixed_timestamp: datetime,
) -> RepresentationCompletedEvent:
"""Create a sample RepresentationCompletedEvent for testing."""
return RepresentationCompletedEvent(
timestamp=fixed_timestamp,
workspace_name="test_workspace",
session_name="test_session",
observed="user_peer",
queue_items_processed=3,
earliest_message_id="msg_001",
latest_message_id="msg_010",
message_count=10,
explicit_conclusion_count=5,
context_preparation_ms=50.0,
llm_call_ms=1200.0,
total_duration_ms=1300.0,
input_tokens=5000,
total_input_tokens=7500,
output_tokens=500,
# additive fields
queued_message_count=3,
prompt_message_count=10,
prompt_message_tokens=7000,
extra_context_message_count=7,
extra_context_tokens=2000,
prompt_scaffold_tokens=500,
batch_max_tokens=20_000,
max_input_tokens=23_000,
was_flush_enabled=False,
hit_batch_token_cap=False,
hit_input_token_cap=False,
observer_count=1,
)
@pytest.fixture
def sample_message_created_event(fixed_timestamp: datetime) -> MessageCreatedEvent:
"""Create a sample MessageCreatedEvent for testing."""
return MessageCreatedEvent(
timestamp=fixed_timestamp,
workspace_name="test_workspace",
session_name="test_session",
message_count=2,
total_tokens=250,
last_message_id="msg_abc123_fixture_____",
)
@pytest.fixture
def sample_file_uploaded_event(fixed_timestamp: datetime) -> FileUploadedEvent:
"""Create a sample FileUploadedEvent for testing."""
return FileUploadedEvent(
timestamp=fixed_timestamp,
workspace_name="test_workspace",
session_name="test_session",
peer_name="user_peer",
file_id="file_123",
filename="notes.txt",
content_type="text/plain",
file_size_bytes=1024,
message_count=2,
total_tokens=250,
)
@pytest.fixture
def sample_llm_call_event(fixed_timestamp: datetime) -> LLMCallCompletedEvent:
"""Sample LLMCallCompletedEvent ()."""
return LLMCallCompletedEvent(
timestamp=fixed_timestamp,
workspace_name="test_workspace",
call_purpose=CallPurpose.DIALECTIC_ANSWER,
parent_category="dialectic",
transport="anthropic",
provider_label=None,
model="claude-sonnet-4-5",
effective_max_output_tokens=4096,
provider_input_tokens=1234,
provider_output_tokens=567,
cache_read_tokens=100,
cache_creation_tokens=50,
finish_reason="stop",
outcome="success",
is_final_attempt=False,
attempt=1,
retry_attempts=3,
was_fallback=False,
duration_ms=1100.5,
has_tools=True,
tool_call_count=2,
run_id="abc12345",
iteration=1,
)
@pytest.fixture
def sample_get_context_event(fixed_timestamp: datetime) -> GetContextEvent:
"""Create a sample GetContextEvent for testing."""
return GetContextEvent(
timestamp=fixed_timestamp,
workspace_name="test_workspace",
context_scope="session",
session_name="test_session",
tokens_requested=4000,
message_count=10,
has_summary=True,
has_representation=False,
has_peer_card=False,
search_query_provided=False,
include_summary=True,
peer_perspective_provided=False,
total_duration_ms=25.0,
)
@pytest.fixture
def sample_dream_run_event(fixed_timestamp: datetime) -> DreamRunEvent:
"""Create a sample DreamRunEvent for testing."""
return DreamRunEvent(
timestamp=fixed_timestamp,
run_id="abc12345",
workspace_name="test_workspace",
session_name="test_session",
observer="assistant",
observed="user_peer",
specialists_run=["deduction", "induction"],
deduction_success=True,
induction_success=True,
surprisal_enabled=False,
surprisal_conclusion_count=0,
total_iterations=15,
total_input_tokens=25000,
total_output_tokens=3000,
total_duration_ms=45000.0,
# additive fields
dream_type="omni",
enabled_types_count=1,
trigger_reason="document_threshold",
delay_reason="idle_timeout",
documents_since_last_dream_at_schedule=55,
document_threshold=50,
)
@pytest.fixture
def sample_dream_specialist_event(fixed_timestamp: datetime) -> DreamSpecialistEvent:
"""Create a sample DreamSpecialistEvent for testing."""
return DreamSpecialistEvent(
timestamp=fixed_timestamp,
run_id="abc12345",
specialist_type="deduction",
workspace_name="test_workspace",
observer="assistant",
observed="user_peer",
iterations=8,
tool_calls_count=12,
input_tokens=15000,
output_tokens=2000,
duration_ms=25000.0,
success=True,
# additive rollups
created_observation_count=7,
deleted_observation_count=2,
peer_card_updated=True,
search_tool_calls_count=4,
)
@pytest.fixture
def sample_dialectic_event(fixed_timestamp: datetime) -> DialecticCompletedEvent:
"""Create a sample DialecticCompletedEvent for testing."""
return DialecticCompletedEvent(
timestamp=fixed_timestamp,
run_id="def67890",
workspace_name="test_workspace",
peer_name="user_peer",
session_name="test_session",
reasoning_level="medium",
total_iterations=3,
prefetched_conclusion_count=10,
tool_calls_count=5,
total_duration_ms=3500.0,
input_tokens=8000,
output_tokens=1200,
cache_read_tokens=500,
cache_creation_tokens=200,
)
@pytest.fixture
def sample_agent_iteration_event(fixed_timestamp: datetime) -> AgentIterationEvent:
"""Create a sample AgentIterationEvent for testing."""
return AgentIterationEvent(
timestamp=fixed_timestamp,
run_id="abc12345",
parent_category="dream",
agent_type="deduction",
workspace_name="test_workspace",
observer="assistant",
observed="user_peer",
iteration=3,
tool_calls=["search_memory", "create_observations"],
input_tokens=3000,
output_tokens=500,
cache_read_tokens=100,
cache_creation_tokens=50,
)
@pytest.fixture
def sample_conclusions_created_event(
fixed_timestamp: datetime,
) -> AgentToolConclusionsCreatedEvent:
"""Create a sample AgentToolConclusionsCreatedEvent for testing."""
return AgentToolConclusionsCreatedEvent(
timestamp=fixed_timestamp,
run_id="abc12345",
iteration=3,
parent_category="dream",
agent_type="deduction",
workspace_name="test_workspace",
observer="assistant",
observed="user_peer",
conclusion_count=5,
levels=["explicit", "deductive", "deductive", "explicit", "deductive"],
)
@pytest.fixture
def sample_conclusions_deleted_event(
fixed_timestamp: datetime,
) -> AgentToolConclusionsDeletedEvent:
"""Create a sample AgentToolConclusionsDeletedEvent for testing."""
return AgentToolConclusionsDeletedEvent(
timestamp=fixed_timestamp,
run_id="abc12345",
iteration=5,
parent_category="dream",
agent_type="deduction",
workspace_name="test_workspace",
observer="assistant",
observed="user_peer",
conclusion_count=3,
levels=["explicit", "deductive", "explicit"],
)
@pytest.fixture
def sample_peer_card_updated_event(
fixed_timestamp: datetime,
) -> AgentToolPeerCardUpdatedEvent:
"""Create a sample AgentToolPeerCardUpdatedEvent for testing."""
return AgentToolPeerCardUpdatedEvent(
timestamp=fixed_timestamp,
run_id="abc12345",
iteration=7,
parent_category="dream",
agent_type="induction",
workspace_name="test_workspace",
observer="assistant",
observed="user_peer",
facts_count=12,
)
@pytest.fixture
def sample_summary_created_event(
fixed_timestamp: datetime,
) -> AgentToolSummaryCreatedEvent:
"""Create a sample AgentToolSummaryCreatedEvent for testing."""
return AgentToolSummaryCreatedEvent(
timestamp=fixed_timestamp,
run_id="ghi11111",
iteration=1,
parent_category="representation",
agent_type="summarizer",
workspace_name="test_workspace",
session_name="test_session",
message_id="msg_020",
message_count=20,
message_seq_in_session=20,
summary_type="short",
input_tokens=4000,
output_tokens=300,
# additive token breakdown
previous_summary_tokens=200,
message_tokens=3500,
prompt_scaffold_tokens=300,
)
@pytest.fixture
def sample_deletion_event(fixed_timestamp: datetime) -> DeletionCompletedEvent:
"""Create a sample DeletionCompletedEvent for testing."""
return DeletionCompletedEvent(
timestamp=fixed_timestamp,
workspace_name="test_workspace",
deletion_type="workspace",
resource_id="ws_123abc",
success=True,
peers_deleted=5,
sessions_deleted=10,
messages_deleted=500,
conclusions_deleted=200,
)
@pytest.fixture
def sample_sync_vectors_event(fixed_timestamp: datetime) -> SyncVectorsCompletedEvent:
"""Create a sample SyncVectorsCompletedEvent for testing."""
return SyncVectorsCompletedEvent(
timestamp=fixed_timestamp,
documents_synced=150,
documents_failed=2,
message_embeddings_synced=500,
message_embeddings_failed=0,
total_duration_ms=12000.0,
)
@pytest.fixture
def sample_cleanup_event(fixed_timestamp: datetime) -> CleanupStaleItemsCompletedEvent:
"""Create a sample CleanupStaleItemsCompletedEvent for testing."""
return CleanupStaleItemsCompletedEvent(
timestamp=fixed_timestamp,
documents_cleaned=25,
queue_items_cleaned=100,
total_duration_ms=5000.0,
)
# =============================================================================
# All sample events as a collection
# =============================================================================
@pytest.fixture
def all_sample_events(
sample_representation_event: RepresentationCompletedEvent,
sample_message_created_event: MessageCreatedEvent,
sample_file_uploaded_event: FileUploadedEvent,
sample_get_context_event: GetContextEvent,
sample_dream_run_event: DreamRunEvent,
sample_dream_specialist_event: DreamSpecialistEvent,
sample_dialectic_event: DialecticCompletedEvent,
sample_agent_iteration_event: AgentIterationEvent,
sample_conclusions_created_event: AgentToolConclusionsCreatedEvent,
sample_conclusions_deleted_event: AgentToolConclusionsDeletedEvent,
sample_peer_card_updated_event: AgentToolPeerCardUpdatedEvent,
sample_summary_created_event: AgentToolSummaryCreatedEvent,
sample_deletion_event: DeletionCompletedEvent,
sample_sync_vectors_event: SyncVectorsCompletedEvent,
sample_cleanup_event: CleanupStaleItemsCompletedEvent,
sample_llm_call_event: LLMCallCompletedEvent,
) -> list[BaseEvent]:
"""Return all sample events as a list for parametrized tests."""
return [
sample_representation_event,
sample_message_created_event,
sample_file_uploaded_event,
sample_get_context_event,
sample_dream_run_event,
sample_dream_specialist_event,
sample_dialectic_event,
sample_agent_iteration_event,
sample_conclusions_created_event,
sample_conclusions_deleted_event,
sample_peer_card_updated_event,
sample_summary_created_event,
sample_deletion_event,
sample_sync_vectors_event,
sample_cleanup_event,
sample_llm_call_event,
]
# =============================================================================
# Mock settings fixtures
# =============================================================================
@pytest.fixture
def mock_telemetry_settings():
"""Fixture to mock telemetry settings.
Returns a context manager that patches settings with configurable values.
"""
def _configure(
enabled: bool = True,
endpoint: str = "http://test-endpoint:8001/v1/events",
namespace: str = "test_namespace",
batch_size: int = 100,
flush_interval: float = 1.0,
flush_threshold: int = 50,
max_retries: int = 3,
max_buffer_size: int = 10000,
headers: dict[str, str] | None = None,
):
mock_settings = MagicMock()
mock_settings.TELEMETRY.ENABLED = enabled
mock_settings.TELEMETRY.ENDPOINT = endpoint
mock_settings.TELEMETRY.NAMESPACE = namespace
mock_settings.TELEMETRY.BATCH_SIZE = batch_size
mock_settings.TELEMETRY.FLUSH_INTERVAL_SECONDS = flush_interval
mock_settings.TELEMETRY.FLUSH_THRESHOLD = flush_threshold
mock_settings.TELEMETRY.MAX_RETRIES = max_retries
mock_settings.TELEMETRY.MAX_BUFFER_SIZE = max_buffer_size
mock_settings.TELEMETRY.HEADERS = headers
mock_settings.METRICS.ENABLED = False
return patch("src.telemetry.emitter.settings", mock_settings)
return _configure
# =============================================================================
# Mock HTTP client fixtures
# =============================================================================
@pytest.fixture
def mock_httpx_client():
"""Create a mock httpx.AsyncClient for testing HTTP calls.
Returns a factory function that creates configured mocks.
"""
def _create(
status_code: int = 200,
raise_exception: Exception | None = None,
response_sequence: list[tuple[int, Exception | None]] | None = None,
):
"""Create a mock HTTP client.
Args:
status_code: Default status code to return
raise_exception: Exception to raise on post()
response_sequence: List of (status_code, exception) for sequential calls
"""
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = status_code
mock_response.raise_for_status = MagicMock()
if raise_exception:
mock_client.post = AsyncMock(side_effect=raise_exception)
elif response_sequence:
# Create side effects for sequential responses
side_effects = []
for code, exc in response_sequence:
if exc:
side_effects.append(exc)
else:
resp = MagicMock()
resp.status_code = code
if code >= 400:
import httpx
resp.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError(
f"HTTP {code}",
request=MagicMock(),
response=resp,
)
)
else:
resp.raise_for_status = MagicMock()
side_effects.append(resp)
mock_client.post = AsyncMock(side_effect=side_effects)
else:
if status_code >= 400:
import httpx
mock_response.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError(
f"HTTP {status_code}",
request=MagicMock(),
response=mock_response,
)
)
mock_client.post = AsyncMock(return_value=mock_response)
mock_client.aclose = AsyncMock()
return mock_client
return _create
# =============================================================================
# Emitter fixtures
# =============================================================================
@pytest.fixture
def disabled_emitter():
"""Create a disabled TelemetryEmitter for testing disabled state."""
from src.telemetry.emitter import TelemetryEmitter
return TelemetryEmitter(
endpoint=None,
enabled=False,
)
@pytest.fixture
def emitter_with_small_buffer():
"""Create an emitter with a small buffer for testing overflow."""
from src.telemetry.emitter import TelemetryEmitter
return TelemetryEmitter(
endpoint="http://test:8001/events",
max_buffer_size=5,
flush_threshold=3,
enabled=True,
)
@pytest.fixture
def emitter_with_low_threshold():
"""Create an emitter with low flush threshold for testing auto-flush."""
from src.telemetry.emitter import TelemetryEmitter
return TelemetryEmitter(
endpoint="http://test:8001/events",
flush_threshold=2,
batch_size=10,
enabled=True,
)