Files
zeekay 3ca982343e rebrand: remove all compat aliases, zero posthog refs
- Remove Posthog = Insights alias from __init__.py
- Remove PosthogContextMiddleware alias from django.py
- Remove PostHogTracingProcessor alias from processor.py
- Change $lib from "posthog-python" to "insights-python"
- Change ingestion URLs from posthog.com to insights.hanzo.ai
- Rename all posthog_* kwargs to insights_* across AI wrappers
- Rename __posthog_exception_captured to __insights_exception_captured
- Rename posthog_context_stack contextvar to insights_context_stack
- Rename posthog🎏 Redis prefix to insights🎏
- Rename $$_posthog_redacted_* sentinels to $$_insights_redacted_*
- Remove POSTHOG_MW_* Django settings fallback, X-POSTHOG-* headers
- Rename Prompts(posthog=) param to Prompts(client=)
- Update APP_ENDPOINT to us.insights.hanzo.ai
- Update all tests, examples, docs, mypy config
2026-03-13 20:29:13 -07:00

811 lines
30 KiB
Python

import logging
from unittest.mock import MagicMock, patch
import pytest
try:
from agents.tracing.span_data import (
AgentSpanData,
CustomSpanData,
FunctionSpanData,
GenerationSpanData,
GuardrailSpanData,
HandoffSpanData,
ResponseSpanData,
SpeechSpanData,
TranscriptionSpanData,
)
from hanzo_insights.ai.openai_agents import InsightsTracingProcessor, instrument
OPENAI_AGENTS_AVAILABLE = True
except ImportError:
OPENAI_AGENTS_AVAILABLE = False
# Skip all tests if OpenAI Agents SDK is not available
pytestmark = pytest.mark.skipif(
not OPENAI_AGENTS_AVAILABLE, reason="OpenAI Agents SDK is not available"
)
@pytest.fixture(scope="function")
def mock_client():
client = MagicMock()
client.privacy_mode = False
logging.getLogger("hanzo_insights").setLevel(logging.DEBUG)
return client
@pytest.fixture(scope="function")
def processor(mock_client):
return InsightsTracingProcessor(
client=mock_client,
distinct_id="test-user",
privacy_mode=False,
)
@pytest.fixture
def mock_trace():
trace = MagicMock()
trace.trace_id = "trace_123456789"
trace.name = "Test Workflow"
trace.group_id = "group_123"
trace.metadata = {"key": "value"}
return trace
@pytest.fixture
def mock_span():
span = MagicMock()
span.trace_id = "trace_123456789"
span.span_id = "span_987654321"
span.parent_id = None
span.started_at = "2024-01-01T00:00:00Z"
span.ended_at = "2024-01-01T00:00:01Z"
span.error = None
return span
class TestInsightsTracingProcessor:
"""Tests for the InsightsTracingProcessor class."""
def test_initialization(self, mock_client):
"""Test processor initializes correctly."""
processor = InsightsTracingProcessor(
client=mock_client,
distinct_id="[email protected]",
privacy_mode=True,
groups={"company": "acme"},
properties={"env": "test"},
)
assert processor._client == mock_client
assert processor._distinct_id == "[email protected]"
assert processor._privacy_mode is True
assert processor._groups == {"company": "acme"}
assert processor._properties == {"env": "test"}
def test_initialization_with_callable_distinct_id(self, mock_client, mock_trace):
"""Test processor with callable distinct_id resolver."""
def resolver(trace):
return trace.metadata.get("user_id", "default")
processor = InsightsTracingProcessor(
client=mock_client,
distinct_id=resolver,
)
mock_trace.metadata = {"user_id": "resolved-user"}
distinct_id = processor._get_distinct_id(mock_trace)
assert distinct_id == "resolved-user"
def test_on_trace_start_stores_metadata(self, processor, mock_client, mock_trace):
"""Test that on_trace_start stores metadata but does not capture an event."""
processor.on_trace_start(mock_trace)
mock_client.capture.assert_not_called()
assert mock_trace.trace_id in processor._trace_metadata
def test_on_trace_end_captures_ai_trace(self, processor, mock_client, mock_trace):
"""Test that on_trace_end captures $ai_trace event."""
processor.on_trace_start(mock_trace)
processor.on_trace_end(mock_trace)
mock_client.capture.assert_called_once()
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_trace"
assert call_kwargs["distinct_id"] == "test-user"
assert call_kwargs["properties"]["$ai_trace_id"] == "trace_123456789"
assert call_kwargs["properties"]["$ai_trace_name"] == "Test Workflow"
assert call_kwargs["properties"]["$ai_provider"] == "openai"
assert call_kwargs["properties"]["$ai_framework"] == "openai-agents"
assert "$ai_latency" in call_kwargs["properties"]
def test_personless_mode_when_no_distinct_id(self, mock_client, mock_trace):
"""Test that trace events use personless mode when no distinct_id is provided."""
processor = InsightsTracingProcessor(
client=mock_client,
)
processor.on_trace_start(mock_trace)
processor.on_trace_end(mock_trace)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$process_person_profile"] is False
# Should fallback to trace_id as the distinct_id
assert call_kwargs["distinct_id"] == mock_trace.trace_id
def test_personless_mode_for_spans_when_no_distinct_id(
self, mock_client, mock_trace, mock_span
):
"""Test that span events use personless mode when no distinct_id is provided."""
processor = InsightsTracingProcessor(
client=mock_client,
)
processor.on_trace_start(mock_trace)
mock_client.capture.reset_mock()
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$process_person_profile"] is False
assert call_kwargs["distinct_id"] == mock_span.trace_id
def test_personless_mode_when_callable_returns_none(
self, mock_client, mock_trace, mock_span
):
"""Test personless mode when callable distinct_id returns None."""
def resolver(trace):
return None # Simulate no user ID available
processor = InsightsTracingProcessor(
client=mock_client,
distinct_id=resolver,
)
processor.on_trace_start(mock_trace)
mock_client.capture.reset_mock()
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$process_person_profile"] is False
assert call_kwargs["distinct_id"] == mock_span.trace_id
def test_person_profile_when_distinct_id_provided(self, mock_client, mock_trace):
"""Test that events create person profiles when distinct_id is provided."""
processor = InsightsTracingProcessor(
client=mock_client,
distinct_id="real-user",
)
processor.on_trace_start(mock_trace)
processor.on_trace_end(mock_trace)
call_kwargs = mock_client.capture.call_args[1]
assert "$process_person_profile" not in call_kwargs["properties"]
def test_on_trace_end_clears_metadata(self, processor, mock_client, mock_trace):
"""Test that on_trace_end clears stored trace metadata."""
processor.on_trace_start(mock_trace)
assert mock_trace.trace_id in processor._trace_metadata
processor.on_trace_end(mock_trace)
assert mock_trace.trace_id not in processor._trace_metadata
# Also verify it captured the event
mock_client.capture.assert_called_once()
def test_on_span_start_tracks_time(self, processor, mock_span):
"""Test that on_span_start records start time."""
processor.on_span_start(mock_span)
assert mock_span.span_id in processor._span_start_times
def test_generation_span_mapping(self, processor, mock_client, mock_span):
"""Test GenerationSpanData maps to $ai_generation event."""
span_data = GenerationSpanData(
input=[{"role": "user", "content": "Hello"}],
output=[{"role": "assistant", "content": "Hi there!"}],
model="gpt-4o",
model_config={"temperature": 0.7, "max_tokens": 100},
usage={"input_tokens": 10, "output_tokens": 20},
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
mock_client.capture.assert_called_once()
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_generation"
assert call_kwargs["properties"]["$ai_trace_id"] == "trace_123456789"
assert call_kwargs["properties"]["$ai_span_id"] == "span_987654321"
assert call_kwargs["properties"]["$ai_provider"] == "openai"
assert call_kwargs["properties"]["$ai_framework"] == "openai-agents"
assert call_kwargs["properties"]["$ai_model"] == "gpt-4o"
assert call_kwargs["properties"]["$ai_input_tokens"] == 10
assert call_kwargs["properties"]["$ai_output_tokens"] == 20
assert call_kwargs["properties"]["$ai_input"] == [
{"role": "user", "content": "Hello"}
]
assert call_kwargs["properties"]["$ai_output_choices"] == [
{"role": "assistant", "content": "Hi there!"}
]
def test_generation_span_with_reasoning_tokens(
self, processor, mock_client, mock_span
):
"""Test GenerationSpanData includes reasoning tokens when present."""
span_data = GenerationSpanData(
model="o1-preview",
usage={
"input_tokens": 100,
"output_tokens": 500,
"reasoning_tokens": 400,
},
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_reasoning_tokens"] == 400
def test_function_span_mapping(self, processor, mock_client, mock_span):
"""Test FunctionSpanData maps to $ai_span event with type=tool."""
span_data = FunctionSpanData(
name="get_weather",
input='{"city": "San Francisco"}',
output="Sunny, 72F",
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_span"
assert call_kwargs["properties"]["$ai_span_name"] == "get_weather"
assert call_kwargs["properties"]["$ai_span_type"] == "tool"
assert (
call_kwargs["properties"]["$ai_input_state"] == '{"city": "San Francisco"}'
)
assert call_kwargs["properties"]["$ai_output_state"] == "Sunny, 72F"
def test_agent_span_mapping(self, processor, mock_client, mock_span):
"""Test AgentSpanData maps to $ai_span event with type=agent."""
span_data = AgentSpanData(
name="CustomerServiceAgent",
handoffs=["TechnicalAgent", "BillingAgent"],
tools=["search", "get_order"],
output_type="str",
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_span"
assert call_kwargs["properties"]["$ai_span_name"] == "CustomerServiceAgent"
assert call_kwargs["properties"]["$ai_span_type"] == "agent"
assert call_kwargs["properties"]["$ai_agent_handoffs"] == [
"TechnicalAgent",
"BillingAgent",
]
assert call_kwargs["properties"]["$ai_agent_tools"] == ["search", "get_order"]
def test_handoff_span_mapping(self, processor, mock_client, mock_span):
"""Test HandoffSpanData maps to $ai_span event with type=handoff."""
span_data = HandoffSpanData(
from_agent="TriageAgent",
to_agent="TechnicalAgent",
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_span"
assert call_kwargs["properties"]["$ai_span_type"] == "handoff"
assert call_kwargs["properties"]["$ai_handoff_from_agent"] == "TriageAgent"
assert call_kwargs["properties"]["$ai_handoff_to_agent"] == "TechnicalAgent"
assert (
call_kwargs["properties"]["$ai_span_name"]
== "TriageAgent -> TechnicalAgent"
)
def test_guardrail_span_mapping(self, processor, mock_client, mock_span):
"""Test GuardrailSpanData maps to $ai_span event with type=guardrail."""
span_data = GuardrailSpanData(
name="ContentFilter",
triggered=True,
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_span"
assert call_kwargs["properties"]["$ai_span_name"] == "ContentFilter"
assert call_kwargs["properties"]["$ai_span_type"] == "guardrail"
assert call_kwargs["properties"]["$ai_guardrail_triggered"] is True
def test_custom_span_mapping(self, processor, mock_client, mock_span):
"""Test CustomSpanData maps to $ai_span event with type=custom."""
span_data = CustomSpanData(
name="database_query",
data={"query": "SELECT * FROM users", "rows": 100},
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_span"
assert call_kwargs["properties"]["$ai_span_name"] == "database_query"
assert call_kwargs["properties"]["$ai_span_type"] == "custom"
assert call_kwargs["properties"]["$ai_custom_data"] == {
"query": "SELECT * FROM users",
"rows": 100,
}
def test_privacy_mode_redacts_content(self, mock_client, mock_span):
"""Test that privacy_mode redacts input/output content."""
processor = InsightsTracingProcessor(
client=mock_client,
distinct_id="test-user",
privacy_mode=True,
)
span_data = GenerationSpanData(
input=[{"role": "user", "content": "Secret message"}],
output=[{"role": "assistant", "content": "Secret response"}],
model="gpt-4o",
usage={"input_tokens": 10, "output_tokens": 20},
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
# Content should be redacted
assert call_kwargs["properties"]["$ai_input"] is None
assert call_kwargs["properties"]["$ai_output_choices"] is None
# Token counts should still be present
assert call_kwargs["properties"]["$ai_input_tokens"] == 10
assert call_kwargs["properties"]["$ai_output_tokens"] == 20
def test_error_handling_in_span(self, processor, mock_client, mock_span):
"""Test that span errors are captured correctly."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {"message": "Rate limit exceeded", "data": {"code": 429}}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_is_error"] is True
assert call_kwargs["properties"]["$ai_error"] == "Rate limit exceeded"
def test_generation_span_includes_total_tokens(
self, processor, mock_client, mock_span
):
"""Test that $ai_total_tokens is calculated and included."""
span_data = GenerationSpanData(
model="gpt-4o",
usage={"input_tokens": 100, "output_tokens": 50},
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_total_tokens"] == 150
def test_error_type_categorization_model_behavior(
self, processor, mock_client, mock_span
):
"""Test that ModelBehaviorError is categorized correctly."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {
"message": "ModelBehaviorError: Invalid JSON output",
"type": "ModelBehaviorError",
}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_error_type"] == "model_behavior_error"
def test_error_type_categorization_user_error(
self, processor, mock_client, mock_span
):
"""Test that UserError is categorized correctly."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {"message": "UserError: Tool failed", "type": "UserError"}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_error_type"] == "user_error"
def test_error_type_categorization_input_guardrail(
self, processor, mock_client, mock_span
):
"""Test that InputGuardrailTripwireTriggered is categorized correctly."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {
"message": "InputGuardrailTripwireTriggered: Content blocked"
}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert (
call_kwargs["properties"]["$ai_error_type"] == "input_guardrail_triggered"
)
def test_error_type_categorization_output_guardrail(
self, processor, mock_client, mock_span
):
"""Test that OutputGuardrailTripwireTriggered is categorized correctly."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {
"message": "OutputGuardrailTripwireTriggered: Response blocked"
}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert (
call_kwargs["properties"]["$ai_error_type"] == "output_guardrail_triggered"
)
def test_error_type_categorization_max_turns(
self, processor, mock_client, mock_span
):
"""Test that MaxTurnsExceeded is categorized correctly."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {"message": "MaxTurnsExceeded: Agent exceeded maximum turns"}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_error_type"] == "max_turns_exceeded"
def test_error_type_categorization_unknown(self, processor, mock_client, mock_span):
"""Test that unknown errors are categorized as unknown."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {"message": "Some random error occurred"}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_error_type"] == "unknown"
def test_response_span_with_output_and_total_tokens(
self, processor, mock_client, mock_span
):
"""Test ResponseSpanData includes output choices and total tokens."""
# Create a mock response object
mock_response = MagicMock()
mock_response.id = "resp_123"
mock_response.model = "gpt-4o"
mock_response.output = [{"type": "message", "content": "Hello!"}]
mock_response.usage = MagicMock()
mock_response.usage.input_tokens = 25
mock_response.usage.output_tokens = 10
span_data = ResponseSpanData(
response=mock_response,
input="Hello, world!",
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_generation"
assert call_kwargs["properties"]["$ai_total_tokens"] == 35
assert call_kwargs["properties"]["$ai_output_choices"] == [
{"type": "message", "content": "Hello!"}
]
assert call_kwargs["properties"]["$ai_response_id"] == "resp_123"
def test_speech_span_with_pass_through_properties(
self, processor, mock_client, mock_span
):
"""Test SpeechSpanData includes pass-through properties."""
span_data = SpeechSpanData(
input="Hello, how can I help you?",
output="base64_audio_data",
output_format="pcm",
model="tts-1",
model_config={"voice": "alloy", "speed": 1.0},
first_content_at="2024-01-01T00:00:00.500Z",
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_span"
assert call_kwargs["properties"]["$ai_span_type"] == "speech"
assert call_kwargs["properties"]["$ai_model"] == "tts-1"
# Pass-through properties (no $ai_ prefix)
assert (
call_kwargs["properties"]["first_content_at"] == "2024-01-01T00:00:00.500Z"
)
assert call_kwargs["properties"]["audio_output_format"] == "pcm"
assert call_kwargs["properties"]["model_config"] == {
"voice": "alloy",
"speed": 1.0,
}
# Text input should be captured
assert call_kwargs["properties"]["$ai_input"] == "Hello, how can I help you?"
def test_transcription_span_with_pass_through_properties(
self, processor, mock_client, mock_span
):
"""Test TranscriptionSpanData includes pass-through properties."""
span_data = TranscriptionSpanData(
input="base64_audio_data",
input_format="pcm",
output="This is the transcribed text.",
model="whisper-1",
model_config={"language": "en"},
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["event"] == "$ai_span"
assert call_kwargs["properties"]["$ai_span_type"] == "transcription"
assert call_kwargs["properties"]["$ai_model"] == "whisper-1"
# Pass-through properties (no $ai_ prefix)
assert call_kwargs["properties"]["audio_input_format"] == "pcm"
assert call_kwargs["properties"]["model_config"] == {"language": "en"}
# Transcription output should be captured
assert (
call_kwargs["properties"]["$ai_output_state"]
== "This is the transcribed text."
)
def test_latency_calculation(self, processor, mock_client, mock_span):
"""Test that latency is calculated correctly."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
with patch("time.time") as mock_time:
mock_time.return_value = 1000.0
processor.on_span_start(mock_span)
mock_time.return_value = 1001.5 # 1.5 seconds later
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_latency"] == pytest.approx(1.5, rel=0.01)
def test_groups_included_in_events(self, mock_client, mock_trace, mock_span):
"""Test that groups are included in captured events."""
processor = InsightsTracingProcessor(
client=mock_client,
distinct_id="test-user",
groups={"company": "acme", "team": "engineering"},
)
processor.on_trace_start(mock_trace)
processor.on_trace_end(mock_trace)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["groups"] == {"company": "acme", "team": "engineering"}
def test_additional_properties_included(self, mock_client, mock_trace):
"""Test that additional properties are included in events."""
processor = InsightsTracingProcessor(
client=mock_client,
distinct_id="test-user",
properties={"environment": "production", "version": "1.0"},
)
processor.on_trace_start(mock_trace)
processor.on_trace_end(mock_trace)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["environment"] == "production"
assert call_kwargs["properties"]["version"] == "1.0"
def test_shutdown_clears_state(self, processor):
"""Test that shutdown clears internal state."""
processor._span_start_times["span_1"] = 1000.0
processor._trace_metadata["trace_1"] = {"name": "test"}
processor.shutdown()
assert len(processor._span_start_times) == 0
assert len(processor._trace_metadata) == 0
def test_force_flush_calls_client_flush(self, processor, mock_client):
"""Test that force_flush calls client.flush()."""
processor.force_flush()
mock_client.flush.assert_called_once()
def test_generation_span_with_no_usage(self, processor, mock_client, mock_span):
"""Test GenerationSpanData with no usage data defaults to zero tokens."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_input_tokens"] == 0
assert call_kwargs["properties"]["$ai_output_tokens"] == 0
assert call_kwargs["properties"]["$ai_total_tokens"] == 0
def test_generation_span_with_partial_usage(
self, processor, mock_client, mock_span
):
"""Test GenerationSpanData with only input_tokens present."""
span_data = GenerationSpanData(
model="gpt-4o",
usage={"input_tokens": 42},
)
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_input_tokens"] == 42
assert call_kwargs["properties"]["$ai_output_tokens"] == 0
assert call_kwargs["properties"]["$ai_total_tokens"] == 42
def test_error_type_categorization_by_type_field_only(
self, processor, mock_client, mock_span
):
"""Test error categorization works when only the type field matches."""
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
mock_span.error = {
"message": "Something went wrong",
"type": "ModelBehaviorError",
}
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["properties"]["$ai_error_type"] == "model_behavior_error"
def test_distinct_id_resolved_from_trace_for_spans(
self, mock_client, mock_trace, mock_span
):
"""Test that spans use the distinct_id resolved at trace start."""
def resolver(trace):
return f"user-{trace.name}"
processor = InsightsTracingProcessor(
client=mock_client,
distinct_id=resolver,
)
# Start trace - this resolves and stores distinct_id
processor.on_trace_start(mock_trace)
mock_client.capture.reset_mock()
# End a span - should use the stored distinct_id from trace
span_data = GenerationSpanData(model="gpt-4o")
mock_span.span_data = span_data
processor.on_span_start(mock_span)
processor.on_span_end(mock_span)
call_kwargs = mock_client.capture.call_args[1]
assert call_kwargs["distinct_id"] == "user-Test Workflow"
def test_eviction_of_stale_entries(self, mock_client):
"""Test that stale entries are evicted when max is exceeded."""
processor = InsightsTracingProcessor(
client=mock_client,
distinct_id="test-user",
)
processor._max_tracked_entries = 10
# Fill beyond max
for i in range(15):
processor._span_start_times[f"span_{i}"] = float(i)
processor._trace_metadata[f"trace_{i}"] = {"name": f"trace_{i}"}
processor._evict_stale_entries()
# Should have evicted half
assert len(processor._span_start_times) <= 10
assert len(processor._trace_metadata) <= 10
class TestInstrumentHelper:
"""Tests for the instrument() convenience function."""
def test_instrument_registers_processor(self, mock_client):
"""Test that instrument() registers a processor."""
with patch("agents.tracing.add_trace_processor") as mock_add:
processor = instrument(
client=mock_client,
distinct_id="test-user",
)
mock_add.assert_called_once_with(processor)
assert isinstance(processor, InsightsTracingProcessor)
def test_instrument_with_privacy_mode(self, mock_client):
"""Test instrument() respects privacy_mode."""
with patch("agents.tracing.add_trace_processor"):
processor = instrument(
client=mock_client,
privacy_mode=True,
)
assert processor._privacy_mode is True
def test_instrument_with_groups_and_properties(self, mock_client):
"""Test instrument() accepts groups and properties."""
with patch("agents.tracing.add_trace_processor"):
processor = instrument(
client=mock_client,
groups={"company": "acme"},
properties={"env": "test"},
)
assert processor._groups == {"company": "acme"}
assert processor._properties == {"env": "test"}