Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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