Skip to content
Merged
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

**Key Features:**
- 🎯 **Conversation Tracking**: Automatic multi-turn conversation tracking with `conversation_context`
- 🤖 **Agent Tracking**: First-class agent identity with `agent_context` (OTel `gen_ai.agent.*` semantic conventions)
- 🔄 **Workflow Management**: Track complex multi-step AI workflows with `workflow_context`
- 🎨 **Zero-Touch Instrumentation**: `@observe()` decorator for automatic tracking
- 📊 **Context Propagation**: Thread-safe attribute tracking across nested operations
Expand All @@ -25,6 +26,7 @@

### Core Tracking
- 🎯 **Conversation Tracking**: Multi-turn conversations with `gen_ai.conversation.id` and turn numbers
- 🤖 **Agent Identity**: Track agents with `gen_ai.agent.id`, `gen_ai.agent.name`, `gen_ai.agent.version` (OTel semantic conventions)
- 🔄 **Workflow Management**: Track multi-step AI operations across LLM calls, tools, and retrievals
- 📊 **Auto-Context Propagation**: Thread-safe context managers that automatically tag all nested operations
- 🎨 **Decorator Pattern**: `@observe()` for zero-touch instrumentation with full input/output/latency tracking
Expand Down Expand Up @@ -143,6 +145,31 @@ with conversation_context(conversation_id="support_123"):
result = lookup_and_respond()
```

### Track Agents

Track agent identity using [OTel GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/) (`gen_ai.agent.*`):

```python
from last9_genai import agent_context

# Track agent identity — all child spans get gen_ai.agent.* attributes
with agent_context(agent_id="support_bot_v2", agent_name="Support Bot", agent_version="2.0"):
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Help me with my order"}]
)
# Span automatically has gen_ai.agent.id, gen_ai.agent.name, gen_ai.agent.version

# Nest with conversations for full context
with conversation_context(conversation_id="session_123", user_id="user_456"):
with agent_context(agent_id="router_agent", agent_name="Router"):
route = classify_intent(query)

with agent_context(agent_id="support_agent", agent_name="Support"):
response = handle_support(query)
# Each agent's spans are tagged separately, both share the conversation
```

### Decorator Pattern (Zero-Touch)

Use `@observe()` for automatic tracking of everything:
Expand Down Expand Up @@ -479,6 +506,11 @@ workflow.llm_calls = 3
# Conversation
gen_ai.conversation.id = "session_123"
gen_ai.conversation.turn_number = 2

# Agent (OTel GenAI semantic conventions)
gen_ai.agent.id = "support_bot_v2"
gen_ai.agent.name = "Support Bot"
gen_ai.agent.version = "2.0"
```

## Model Pricing
Expand Down Expand Up @@ -569,6 +601,7 @@ See [`examples/`](./examples/) directory:

**Advanced:**
- [`conversation_tracking.py`](./examples/conversation_tracking.py) - Multi-turn conversations
- [`agent_tracking.py`](./examples/agent_tracking.py) - Agent identity tracking with OTel semantic conventions

## Contributing

Expand Down
140 changes: 140 additions & 0 deletions examples/agent_tracking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
Agent identity tracking example

Demonstrates tracking agent identity using OTel GenAI semantic conventions
(gen_ai.agent.id, gen_ai.agent.name, gen_ai.agent.version).

This is useful for multi-agent systems where you need to attribute spans
to specific agents and correlate their interactions.
"""

import sys
import os

sys.path.append(os.path.dirname(os.path.dirname(__file__)))

import time
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
from last9_genai import (
Last9SpanProcessor,
ModelPricing,
agent_context,
conversation_context,
workflow_context,
)


def setup_tracing():
"""Set up OpenTelemetry tracing with Last9 auto-enrichment"""
provider = TracerProvider()
trace.set_tracer_provider(provider)

console_exporter = ConsoleSpanExporter()
provider.add_span_processor(BatchSpanProcessor(console_exporter))

custom_pricing = {
"gpt-4o": ModelPricing(input=2.50, output=10.0),
"gpt-4o-mini": ModelPricing(input=0.15, output=0.60),
}
l9_processor = Last9SpanProcessor(custom_pricing=custom_pricing)
provider.add_span_processor(l9_processor)

return trace.get_tracer(__name__)


def simulate_llm_call(tracer, model: str, prompt: str) -> dict:
"""Simulate an LLM API call"""
with tracer.start_span("gen_ai.chat.completions") as span:
time.sleep(0.05)
span.set_attribute("gen_ai.request.model", model)
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.usage.input_tokens", len(prompt.split()) * 2)
span.set_attribute("gen_ai.usage.output_tokens", 50)
return {"response": f"Response to: {prompt[:40]}..."}


def single_agent_example():
"""Basic agent context example"""
tracer = setup_tracing()

print("\n--- Example 1: Single agent tracking ---\n")

with agent_context(agent_id="support_bot_v2", agent_name="Support Bot", agent_version="2.0"):
result = simulate_llm_call(tracer, "gpt-4o", "Help me with my order")
print(f" Response: {result['response']}")

print("\n Span attributes:")
print(" gen_ai.agent.id = 'support_bot_v2'")
print(" gen_ai.agent.name = 'Support Bot'")
print(" gen_ai.agent.version = '2.0'")


def multi_agent_routing_example():
"""Multi-agent system with routing"""
tracer = setup_tracing()

print("\n--- Example 2: Multi-agent routing ---\n")

with conversation_context(conversation_id="session_abc", user_id="user_42"):
# Router agent classifies intent
with agent_context(agent_id="router_v1", agent_name="Router Agent"):
intent = simulate_llm_call(tracer, "gpt-4o-mini", "Classify: refund my order")
print(f" Router: {intent['response']}")

# Specialist agent handles the request
with agent_context(
agent_id="refund_agent_v3", agent_name="Refund Agent", agent_version="3.1"
):
response = simulate_llm_call(tracer, "gpt-4o", "Process refund for order #12345")
print(f" Refund Agent: {response['response']}")

print("\n Router spans: gen_ai.agent.id='router_v1', conversation_id='session_abc'")
print(" Refund spans: gen_ai.agent.id='refund_agent_v3', conversation_id='session_abc'")


def agent_with_workflow_example():
"""Agent context nested with workflow context"""
tracer = setup_tracing()

print("\n--- Example 3: Agent + workflow nesting ---\n")

with conversation_context(conversation_id="session_xyz"):
with agent_context(agent_id="rag_agent", agent_name="RAG Agent", agent_version="1.0"):
with workflow_context(workflow_id="retrieval_pipeline", workflow_type="rag"):
simulate_llm_call(tracer, "gpt-4o-mini", "Expand query: best restaurants")
simulate_llm_call(tracer, "gpt-4o", "Synthesize answer from documents")
print(" RAG pipeline completed")

print("\n All spans have:")
print(" gen_ai.conversation.id = 'session_xyz'")
print(" gen_ai.agent.id = 'rag_agent'")
print(" workflow.id = 'retrieval_pipeline'")


if __name__ == "__main__":
print("Last9 GenAI - Agent Identity Tracking (OTel Semantic Conventions)")
print("=" * 70)

try:
single_agent_example()
multi_agent_routing_example()
agent_with_workflow_example()

trace.get_tracer_provider().force_flush(timeout_millis=5000)

print("\n" + "=" * 70)
print("All agent tracking examples completed!")
print("\nAttributes follow OTel GenAI semantic conventions:")
print(" gen_ai.agent.id - Unique agent identifier")
print(" gen_ai.agent.name - Human-readable name")
print(" gen_ai.agent.version - Agent version")
print("\nSee: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/")

except Exception as e:
print(f"Error: {e}")
import traceback

traceback.print_exc()
28 changes: 27 additions & 1 deletion examples/context_tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
ModelPricing,
conversation_context,
workflow_context,
agent_context,
propagate_attributes,
)

Expand Down Expand Up @@ -202,11 +203,35 @@ def simulated_chat_endpoint(session_id: str, user_id: str, message: str):
print(" - Zero manual attribute setting!")


def agent_context_example():
"""Agent identity tracking with OTel semantic conventions"""
tracer = setup_tracing()

print("\n🔄 Example 5: Agent identity tracking\n")

with conversation_context(conversation_id="multi_agent_session", user_id="user_agent"):
# Router agent
with agent_context(agent_id="router_v1", agent_name="Router Agent"):
simulate_llm_call(tracer, "gpt-3.5-turbo", "Classify user intent")
print(" ✅ Router agent classified intent")

# Specialist agent
with agent_context(agent_id="support_v2", agent_name="Support Agent", agent_version="2.0"):
simulate_llm_call(tracer, "gpt-4o", "Handle support request")
print(" ✅ Support agent handled request")

print("\n Agent spans automatically have:")
print(" - gen_ai.agent.id (unique per agent)")
print(" - gen_ai.agent.name (human-readable)")
print(" - gen_ai.agent.version (when provided)")
print(" - gen_ai.conversation.id (from parent context)")


def multi_turn_conversation_example():
"""Example with turn numbers"""
tracer = setup_tracing()

print("\n🔄 Example 5: Multi-turn conversation with turn tracking\n")
print("\n🔄 Example 6: Multi-turn conversation with turn tracking\n")

conversation_id = "multi_turn_session"
messages = ["Hello!", "What's the weather?", "Thank you!"]
Expand Down Expand Up @@ -236,6 +261,7 @@ def multi_turn_conversation_example():
nested_workflow_example()
propagate_attributes_example()
fastapi_pattern_example()
agent_context_example()
multi_turn_conversation_example()

# Force export of spans
Expand Down
2 changes: 2 additions & 0 deletions last9_genai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
propagate_attributes,
conversation_context,
workflow_context,
agent_context,
get_current_context,
clear_context,
)
Expand Down Expand Up @@ -93,6 +94,7 @@
"propagate_attributes",
"conversation_context",
"workflow_context",
"agent_context",
"get_current_context",
"clear_context",
# Span processor
Expand Down
77 changes: 77 additions & 0 deletions last9_genai/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
_user_id: ContextVar[Optional[str]] = ContextVar("user_id", default=None)
_workflow_id: ContextVar[Optional[str]] = ContextVar("workflow_id", default=None)
_workflow_type: ContextVar[Optional[str]] = ContextVar("workflow_type", default=None)
_agent_id: ContextVar[Optional[str]] = ContextVar("agent_id", default=None)
_agent_name: ContextVar[Optional[str]] = ContextVar("agent_name", default=None)
_agent_version: ContextVar[Optional[str]] = ContextVar("agent_version", default=None)
_custom_attributes: ContextVar[Dict[str, Any]] = ContextVar("custom_attributes", default={})


Expand Down Expand Up @@ -77,6 +80,18 @@ def get_current_context() -> Dict[str, Any]:
if workflow_type is not None:
context["workflow_type"] = workflow_type

agent_id = _agent_id.get()
if agent_id is not None:
context["agent_id"] = agent_id

agent_name = _agent_name.get()
if agent_name is not None:
context["agent_name"] = agent_name

agent_version = _agent_version.get()
if agent_version is not None:
context["agent_version"] = agent_version

turn_number = _conversation_turn.get()
if turn_number is not None:
context["turn_number"] = turn_number
Expand All @@ -94,6 +109,9 @@ def clear_context() -> None:
_user_id.set(None)
_workflow_id.set(None)
_workflow_type.set(None)
_agent_id.set(None)
_agent_name.set(None)
_agent_version.set(None)
_conversation_turn.set(None)
_custom_attributes.set({})

Expand Down Expand Up @@ -211,3 +229,62 @@ def workflow_context(
_workflow_type.set(prev_wf_type)
_user_id.set(prev_user_id)
_custom_attributes.set(prev_custom)


@contextmanager
def agent_context(
agent_id: str,
agent_name: Optional[str] = None,
agent_version: Optional[str] = None,
**custom_attrs,
):
"""
Context manager for agent tracking using OTel GenAI semantic conventions.

All spans created within this context will automatically have
gen_ai.agent.id, gen_ai.agent.name, and gen_ai.agent.version attributes.

Args:
agent_id: Unique agent identifier (gen_ai.agent.id)
agent_name: Human-readable agent name (gen_ai.agent.name)
agent_version: Agent version (gen_ai.agent.version)
**custom_attrs: Additional custom attributes

Example:
```python
with agent_context(agent_id="agent_123", agent_name="Support Bot", agent_version="2.0"):
# All spans automatically tagged with gen_ai.agent.id
response = client.chat.completions.create(...)
```

# Can be nested with conversation and workflow contexts:
```python
with conversation_context(conversation_id="session_123"):
with agent_context(agent_id="agent_xyz", agent_name="Router"):
# Both conversation AND agent tracked
result = route_request(query)
```
"""
prev_agent_id = _agent_id.get()
prev_agent_name = _agent_name.get()
prev_agent_version = _agent_version.get()
prev_custom = _custom_attributes.get()

try:
_agent_id.set(agent_id)
if agent_name is not None:
_agent_name.set(agent_name)
if agent_version is not None:
_agent_version.set(agent_version)
if custom_attrs:
merged = prev_custom.copy() if prev_custom else {}
merged.update(custom_attrs)
_custom_attributes.set(merged)

yield

finally:
_agent_id.set(prev_agent_id)
_agent_name.set(prev_agent_name)
_agent_version.set(prev_agent_version)
_custom_attributes.set(prev_custom)
Loading
Loading