5a558eb09e
Backend Tests / discover-tests (push) Waiting to run
Backend Tests / ${{ matrix.name }} (push) Blocked by required conditions
Build and Publish SDK / build-and-publish (push) Waiting to run
Build Opik Docker Images / set-version (push) Waiting to run
Build Opik Docker Images / build-backend (push) Blocked by required conditions
Build Opik Docker Images / build-sandbox-executor-python (push) Blocked by required conditions
Build Opik Docker Images / build-python-backend (push) Blocked by required conditions
Build Opik Docker Images / build-frontend (push) Blocked by required conditions
Build Opik Docker Images / create-git-tag (push) Blocked by required conditions
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Waiting to run
Docs - Publish / run (push) Waiting to run
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Waiting to run
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Blocked by required conditions
Frontend Unit Tests / Test on Node 20 (push) Waiting to run
Guardrails E2E Tests / Select Python version matrix (push) Waiting to run
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Blocked by required conditions
Guardrails E2E Tests / 📢 Slack Notification (push) Blocked by required conditions
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Waiting to run
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Blocked by required conditions
SDK Library Integration Tests Runner / Check Secrets (push) Waiting to run
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Blocked by required conditions
SDK Library Integration Tests Runner / Build (push) Blocked by required conditions
SDK Library Integration Tests Runner / openai_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / langchain_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / llama_index_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / anthropic_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / mistral_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / groq_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / aisuite_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / haystack_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / dspy_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / crewai_v0_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / crewai_v1_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / genai_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / adk_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / bedrock_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / litellm_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / harbor_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / Slack Notification (push) Blocked by required conditions
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Waiting to run
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Waiting to run
Lint Opik Helm Chart / unittest-helm-chart (push) Waiting to run
Lint Opik Helm Chart / render-equality (push) Waiting to run
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Waiting to run
Python BE E2E Tests / Python BE E2E (push) Waiting to run
Python Backend Tests / run-python-backend-tests (push) Waiting to run
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
SDK E2E Libraries Integration Tests / Check Secrets (push) Waiting to run
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Blocked by required conditions
SDK E2E Libraries Integration Tests / build-opik (push) Blocked by required conditions
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Blocked by required conditions
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Waiting to run
TypeScript SDK Library Integration Tests / Check Secrets (push) Waiting to run
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Blocked by required conditions
TypeScript SDK Build & Publish / build-and-publish (push) Waiting to run
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Waiting to run
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
1952 lines
57 KiB
Markdown
1952 lines
57 KiB
Markdown
# Opik Python SDK: API and Data Flow
|
|
|
|
## Table of Contents
|
|
|
|
- [Overview](#overview)
|
|
- [High-Level API](#high-level-api)
|
|
- [Core Architecture](#core-architecture)
|
|
- [Data Flow](#data-flow)
|
|
- [Message Processing Deep Dive](#message-processing-deep-dive)
|
|
- [Batching System](#batching-system)
|
|
- [Observability](#observability)
|
|
- [Performance Considerations](#performance-considerations)
|
|
|
|
## Overview
|
|
|
|
The Opik Python SDK provides **lightweight, non-blocking tracing** for LLM applications. The architecture prioritizes minimal performance impact on user code through asynchronous message processing, intelligent batching, and background workers.
|
|
|
|
### Key Design Goals
|
|
|
|
1. **Non-blocking**: User code never waits for backend communication
|
|
2. **Low overhead**: Minimal CPU and memory footprint
|
|
3. **Reliable**: Message queues and retries ensure data delivery
|
|
4. **Observable**: Comprehensive logging and error tracking
|
|
5. **Scalable**: Handles high-throughput applications
|
|
|
|
## High-Level API
|
|
|
|
### Main Entry Point: `opik.Opik`
|
|
|
|
The `Opik` class is the central entry point and factory for all SDK operations.
|
|
|
|
**Location**: `opik/api_objects/opik_client.py`
|
|
|
|
```python
|
|
import opik
|
|
|
|
# Initialize client
|
|
client = opik.Opik(
|
|
project_name="my_project", # Optional: defaults to "Default Project"
|
|
workspace="my_workspace", # Optional: defaults to "default"
|
|
host="https://api.opik.com", # Optional: custom backend URL
|
|
api_key="your_api_key" # Optional: for cloud deployments
|
|
)
|
|
```
|
|
|
|
#### Configuration Priority
|
|
|
|
Configuration is resolved in this order (highest to lowest):
|
|
1. **Direct parameters** to `Opik()` constructor
|
|
2. **Environment variables** (`OPIK_PROJECT_NAME`, `OPIK_WORKSPACE`, etc.)
|
|
3. **Configuration file** (`~/.opik.config`)
|
|
4. **Default values**
|
|
|
|
### Core API Methods
|
|
|
|
#### Manual Tracing
|
|
|
|
```python
|
|
# Create a trace
|
|
trace = client.trace(
|
|
name="my_operation",
|
|
input={"query": "What is AI?"},
|
|
metadata={"version": "1.0"},
|
|
tags=["production"]
|
|
)
|
|
|
|
# Create a span (must be within trace context or provide trace_id)
|
|
span = client.span(
|
|
name="llm_call",
|
|
trace_id=trace.id, # Required if no trace context
|
|
parent_span_id=None, # Optional: for nested spans
|
|
input={"prompt": "..."},
|
|
type="llm", # Types: "llm", "tool", "general"
|
|
model="gpt-4",
|
|
provider="openai"
|
|
)
|
|
|
|
# Update trace/span
|
|
client.trace(
|
|
id=trace.id,
|
|
output={"answer": "..."},
|
|
metadata={"tokens": 150}
|
|
)
|
|
|
|
# Add feedback scores
|
|
client.log_traces_feedback_scores(
|
|
scores=[{
|
|
"id": trace.id,
|
|
"name": "accuracy",
|
|
"value": 0.95,
|
|
"reason": "Accurate response"
|
|
}]
|
|
)
|
|
|
|
# Ensure all data is sent
|
|
client.flush(timeout=30) # Wait up to 30 seconds
|
|
```
|
|
|
|
#### Automatic Tracing with Decorators
|
|
|
|
```python
|
|
import opik
|
|
|
|
# Simplest usage
|
|
@opik.track
|
|
def my_function(input: str) -> str:
|
|
# Automatically creates trace and span
|
|
# Captures input and output
|
|
return process(input)
|
|
|
|
# With options
|
|
@opik.track(
|
|
name="custom_name", # Override function name
|
|
project_name="my_project", # Set project
|
|
capture_input=True, # Capture inputs (default: True)
|
|
capture_output=True, # Capture outputs (default: True)
|
|
tags=["production"], # Add tags
|
|
metadata={"version": "1.0"}, # Add metadata
|
|
type="llm" # Set span type
|
|
)
|
|
def llm_call(prompt: str) -> str:
|
|
return call_llm(prompt)
|
|
|
|
# Nested functions create nested spans
|
|
@opik.track
|
|
def outer_function(data):
|
|
preprocessed = preprocess(data) # Creates nested span
|
|
result = process(preprocessed) # Creates nested span
|
|
return result
|
|
|
|
@opik.track
|
|
def preprocess(data):
|
|
return {"cleaned": data}
|
|
|
|
@opik.track
|
|
def process(data):
|
|
return {"result": data}
|
|
```
|
|
|
|
#### Context Management
|
|
|
|
```python
|
|
import opik
|
|
|
|
# Access current context
|
|
trace_data = opik.get_current_trace_data()
|
|
span_data = opik.get_current_span_data()
|
|
|
|
# Update current trace/span
|
|
opik.update_current_span(
|
|
metadata={"key": "value"},
|
|
tags=["important"],
|
|
usage={"completion_tokens": 100, "prompt_tokens": 50, "total_tokens": 150}
|
|
)
|
|
|
|
opik.update_current_trace(
|
|
output={"result": "success"}
|
|
)
|
|
|
|
# Context managers for manual control
|
|
with opik.start_as_current_trace(name="my_trace", input={"data": "test"}) as trace:
|
|
# Trace context active
|
|
with opik.start_as_current_span(name="step1", type="tool") as span:
|
|
# Span context active
|
|
do_work()
|
|
# Span auto-closed
|
|
# Trace auto-closed
|
|
|
|
# Distributed tracing
|
|
headers = opik.get_distributed_trace_headers()
|
|
# Pass headers to remote service
|
|
# Remote service continues trace: opik.track(distributed_headers=headers)
|
|
```
|
|
|
|
#### Resource Management
|
|
|
|
```python
|
|
# Create and manage datasets
|
|
dataset = client.create_dataset(
|
|
name="my_dataset",
|
|
description="Test dataset"
|
|
)
|
|
|
|
dataset.insert([
|
|
{"input": "query1", "expected": "answer1"},
|
|
{"input": "query2", "expected": "answer2"}
|
|
])
|
|
|
|
# Create experiments
|
|
experiment = client.create_experiment(
|
|
name="exp_v1",
|
|
dataset_name="my_dataset"
|
|
)
|
|
|
|
# Manage prompts
|
|
prompt = client.create_prompt(
|
|
name="my_prompt",
|
|
prompt="Answer this question: {{question}}",
|
|
type="text"
|
|
)
|
|
|
|
# Create new version
|
|
prompt_v2 = prompt.create_version(
|
|
prompt="Enhanced: {{question}}\nContext: {{context}}"
|
|
)
|
|
```
|
|
|
|
## Core Architecture
|
|
|
|
### Layered Architecture
|
|
|
|
The SDK is organized into 3 layers:
|
|
|
|
```
|
|
┌──────────────────────────────────────────────────────────────┐
|
|
│ Layer 1: Public API │
|
|
│ │
|
|
│ opik.Opik, @opik.track, opik_context │
|
|
│ - User-facing interface │
|
|
│ - Input validation │
|
|
│ - Context management │
|
|
└─────────────┬────────────────────────────┬───────────────────┘
|
|
│ │
|
|
│ Observability │ Resource Management
|
|
│ (trace, span, feedback) │ (dataset, experiment,
|
|
│ │ prompt, search, etc.)
|
|
│ │
|
|
▼ ▼
|
|
┌──────────────────────────────┐ ┌──────────────────────────┐
|
|
│ Layer 2: Message Processing │ │ API Object Clients │
|
|
│ (Observability operations) │ │ (Resource operations) │
|
|
│ │ │ │
|
|
│ Streamer │ │ Dataset, Experiment, │
|
|
│ ↓ │ │ Prompt, Attachment, │
|
|
│ Queue │ │ Threads clients │
|
|
│ ↓ │ │ │
|
|
│ Consumers │ │ - Manage state │
|
|
│ ↓ │ │ - Handle complex logic │
|
|
│ MessageProcessor │ │ - Wrap REST calls │
|
|
│ │ │ │
|
|
│ - Background async │ │ Delegates to ↓ │
|
|
│ - Batching │ └──────────────┬───────────┘
|
|
│ - Retry logic │ │
|
|
│ │ │
|
|
│ Delegates to ↓ │ │
|
|
└───────────────┬──────────────┘ │
|
|
│ │
|
|
└────────────────┬───────────────┘
|
|
▼
|
|
┌───────────────────────────────────────────────┐
|
|
│ Layer 3: REST API Client │
|
|
│ │
|
|
│ OpikApi (auto-generated from OpenAPI) │
|
|
│ - HTTP client │
|
|
│ - Request/response serialization │
|
|
│ - Connection pooling │
|
|
│ │
|
|
│ ═══════════════════════════════════════ │
|
|
│ ║ HTTP requests to Opik Backend ║ │
|
|
│ ║ (External service, not part of SDK)║ │
|
|
│ ═══════════════════════════════════════ │
|
|
└───────────────────────────────────────────────┘
|
|
```
|
|
|
|
**Key Points**:
|
|
- **Layer 1 (Public API)**: What users interact with directly (`opik.Opik`, `@opik.track`)
|
|
- **Layer 2 (Message Processing)**: Background workers - **only for observability operations** (trace/span/feedback)
|
|
- **API Object Clients**: Intermediate layer for resource management - handle state and complex logic
|
|
- **Layer 3 (REST API Client)**: HTTP communication layer (used by both Layer 2 and API Object Clients)
|
|
- **Opik Backend**: External service (not part of SDK) that receives HTTP requests
|
|
|
|
**Two Execution Paths**:
|
|
|
|
1. **Observability operations** (trace/span/feedback):
|
|
- `opik.Opik` → **Message Processing** (Layer 2) → REST API Client → Backend
|
|
- Non-blocking, uses background workers
|
|
|
|
2. **Resource management operations** (dataset/experiment/prompt):
|
|
- `opik.Opik` → **API Object Clients** → REST API Client → Backend
|
|
- Blocking, returns objects
|
|
|
|
**API Object Clients** (`opik/api_objects/`):
|
|
|
|
For complex resource types, intermediate client classes provide:
|
|
- **State management**: Dataset items, experiment state, prompt versions
|
|
- **Business logic**: Item insertion, versioning, validation
|
|
- **Convenience methods**: `dataset.insert()`, `experiment.get_items()`, `prompt.format()`
|
|
- **REST abstraction**: Wrap multiple REST calls into higher-level operations
|
|
|
|
**Examples**:
|
|
- `Dataset` (`dataset/dataset.py`) - Manages dataset items, handles insertion/deletion
|
|
- `Experiment` (`experiment/experiment.py`) - Tracks experiment items, links to dataset
|
|
- `Prompt` (`prompt/prompt.py`) - Manages prompt versions and templating
|
|
- `AttachmentClient` (`attachment/client.py`) - Handles file attachments
|
|
- `ThreadsClient` (`threads/threads_client.py`) - Manages conversational threads
|
|
|
|
For simple operations (search, get), `opik.Opik` calls REST client directly without intermediate client.
|
|
|
|
### Synchronous vs Asynchronous Operations
|
|
|
|
The Opik client provides two types of operations with different execution paths:
|
|
|
|
#### Asynchronous Operations (via Layer 2: Message Processing)
|
|
|
|
**Observability operations** that use background processing:
|
|
|
|
| Operation | Purpose | Returns |
|
|
|-----------|---------|---------|
|
|
| `trace()` | Create/update trace | None (fire-and-forget) |
|
|
| `span()` | Create/update span | None (fire-and-forget) |
|
|
| `log_traces_feedback_scores()` | Add feedback to traces | None |
|
|
| `log_spans_feedback_scores()` | Add feedback to spans | None |
|
|
| `experiment.insert()` | Create experiment items | None |
|
|
| Attachment uploads | Upload files to S3 | None |
|
|
|
|
**Flow**: API → **Message** → **Streamer** → **Queue** → **Consumer** → REST Client → Backend
|
|
|
|
**Characteristics**:
|
|
- ⚡ Non-blocking (returns immediately)
|
|
- 📦 Supports batching (Create messages batch together)
|
|
- 🔁 Automatic retries
|
|
- ⚠️ Requires `flush()` before app exit
|
|
|
|
#### Synchronous Operations (via API Object Clients or Direct REST)
|
|
|
|
**Resource management and query operations** that bypass message processing:
|
|
|
|
| Category | Operations | Uses |
|
|
|----------|-----------|------|
|
|
| **Dataset** | `create_dataset()`, `get_dataset()`, `delete_dataset()` | Dataset client |
|
|
| **Experiment** | `create_experiment()`, `get_experiment_by_id()` | Experiment client |
|
|
| **Prompt** | `create_prompt()`, `get_prompt()`, `update_prompt()` | Prompt client |
|
|
| **Search** | `search_traces()`, `search_spans()` | Direct REST |
|
|
| **Retrieval** | `get_trace_content()`, `get_span_content()` | Direct REST |
|
|
| **Delete** | `delete_traces()`, `delete_*_feedback_score()` | Direct REST |
|
|
|
|
**Flow (with API Object Client)**:
|
|
```
|
|
client.create_dataset(name) → Dataset.__init__() → REST Client → Backend
|
|
↓
|
|
Returns Dataset object with methods
|
|
```
|
|
|
|
**Flow (direct REST)**:
|
|
```
|
|
client.search_traces() → REST Client → Backend → Returns List[TracePublic]
|
|
```
|
|
|
|
**Characteristics**:
|
|
- 🔒 Blocking (waits for response)
|
|
- ✅ Returns data immediately
|
|
- 🚫 No batching
|
|
- ⏱️ No flush needed
|
|
|
|
#### Why Different Paths?
|
|
|
|
**Async path** (observability):
|
|
- High frequency (100s-1000s per second)
|
|
- Performance-critical (shouldn't slow down user code)
|
|
- Can be batched (many traces/spans combined)
|
|
- Fire-and-forget (no immediate result needed)
|
|
|
|
**Sync path** (resources):
|
|
- Low frequency (setup/teardown operations)
|
|
- Returns objects needed for further operations
|
|
- Can't be batched (unique operations)
|
|
- User expects to wait for result
|
|
|
|
### Key Components
|
|
|
|
#### 1. Context Storage (`opik/context_storage.py`)
|
|
|
|
Manages trace and span context using Python's `contextvars` for proper isolation.
|
|
|
|
```python
|
|
class OpikContextStorage:
|
|
def __init__(self):
|
|
# Context variables for isolation
|
|
self._current_trace_data_context: ContextVar[Optional[TraceData]]
|
|
self._spans_data_stack_context: ContextVar[Tuple[SpanData, ...]]
|
|
|
|
def set_trace_data(self, trace_data: TraceData) -> None:
|
|
"""Set current trace in context"""
|
|
|
|
def add_span_data(self, span_data: SpanData) -> None:
|
|
"""Push span onto stack"""
|
|
|
|
def pop_span_data(self) -> Optional[SpanData]:
|
|
"""Pop span from stack"""
|
|
|
|
def top_span_data(self) -> Optional[SpanData]:
|
|
"""Get current span without removing"""
|
|
```
|
|
|
|
**Why contextvars?**
|
|
- Automatic isolation across threads
|
|
- Works with async/await
|
|
- No manual cleanup needed
|
|
- Thread-safe by design
|
|
|
|
#### 2. Streamer (`opik/message_processing/streamer.py`)
|
|
|
|
Routes messages to appropriate handlers (queue, batch, or upload).
|
|
|
|
```python
|
|
class Streamer:
|
|
def __init__(
|
|
self,
|
|
queue: MessageQueue,
|
|
queue_consumers: List[QueueConsumer],
|
|
batch_manager: Optional[BatchManager],
|
|
file_upload_manager: FileUploadManager
|
|
):
|
|
self._message_queue = queue
|
|
self._queue_consumers = queue_consumers
|
|
self._batch_manager = batch_manager
|
|
self._file_upload_manager = file_upload_manager
|
|
|
|
def put(self, message: BaseMessage) -> None:
|
|
"""Route message based on type"""
|
|
if self._batch_manager and message.supports_batching:
|
|
self._batch_manager.process_message(message)
|
|
elif message.supports_upload:
|
|
self._file_upload_manager.upload(message)
|
|
else:
|
|
self._message_queue.put(message)
|
|
|
|
def flush(self, timeout: Optional[float]) -> bool:
|
|
"""Wait for all messages to be processed"""
|
|
|
|
def close(self, timeout: Optional[int]) -> bool:
|
|
"""Stop processing and cleanup"""
|
|
```
|
|
|
|
#### 3. Message Queue (`opik/message_processing/message_queue.py`)
|
|
|
|
Thread-safe FIFO queue with backpressure handling.
|
|
|
|
```python
|
|
class MessageQueue(Generic[T]):
|
|
def __init__(self, max_length: Optional[int] = None):
|
|
self._queue: queue.Queue[T] = queue.Queue()
|
|
self._max_length = max_length
|
|
|
|
def put(self, item: T) -> None:
|
|
"""Add message, discard oldest if full"""
|
|
if self._max_length and self._queue.qsize() >= self._max_length:
|
|
# Remove oldest message
|
|
try:
|
|
self._queue.get_nowait()
|
|
except queue.Empty:
|
|
pass
|
|
self._queue.put(item)
|
|
|
|
def get(self, timeout: float) -> Optional[T]:
|
|
"""Get next message"""
|
|
return self._queue.get(timeout=timeout)
|
|
|
|
def empty(self) -> bool:
|
|
"""Check if queue is empty"""
|
|
return self._queue.empty()
|
|
```
|
|
|
|
#### 4. Queue Consumer (`opik/message_processing/queue_consumer.py`)
|
|
|
|
Worker thread that processes messages from the queue.
|
|
|
|
```python
|
|
class QueueConsumer(threading.Thread):
|
|
def __init__(
|
|
self,
|
|
queue: MessageQueue,
|
|
message_processor: MessageProcessor,
|
|
name: Optional[str] = None
|
|
):
|
|
super().__init__(daemon=True, name=name)
|
|
self._message_queue = queue
|
|
self._message_processor = message_processor
|
|
self.next_message_time = 0.0 # For rate limiting
|
|
|
|
def run(self) -> None:
|
|
"""Main worker loop"""
|
|
while not self._processing_stopped:
|
|
self._loop()
|
|
|
|
def _loop(self) -> None:
|
|
"""Process one message"""
|
|
# Check rate limiting
|
|
if time.monotonic() < self.next_message_time:
|
|
time.sleep(SLEEP_INTERVAL)
|
|
return
|
|
|
|
# Get and process message
|
|
try:
|
|
message = self._message_queue.get(timeout=SLEEP_INTERVAL)
|
|
if message and message.delivery_time <= time.monotonic():
|
|
self._message_processor.process(message)
|
|
except RateLimitError as e:
|
|
# Re-queue message with delay
|
|
self.next_message_time = time.monotonic() + e.retry_after
|
|
self._message_queue.put(message)
|
|
```
|
|
|
|
#### 5. Message Processor (`opik/message_processing/message_processors.py`)
|
|
|
|
Maps message types to REST API handlers.
|
|
|
|
```python
|
|
class OpikMessageProcessor(BaseMessageProcessor):
|
|
def __init__(self, rest_client: OpikApi):
|
|
self._rest_client = rest_client
|
|
|
|
# Map message types to handlers
|
|
self._handlers: Dict[Type, MessageHandler] = {
|
|
CreateTraceMessage: self._process_create_trace_message,
|
|
CreateSpanMessage: self._process_create_span_message,
|
|
UpdateTraceMessage: self._process_update_trace_message,
|
|
UpdateSpanMessage: self._process_update_span_message,
|
|
AddTraceFeedbackScoresBatchMessage: self._process_feedback_scores,
|
|
CreateSpansBatchMessage: self._process_create_spans_batch,
|
|
CreateTraceBatchMessage: self._process_create_traces_batch,
|
|
# ... more handlers
|
|
}
|
|
|
|
def process(self, message: BaseMessage) -> None:
|
|
"""Process message by calling appropriate handler"""
|
|
handler = self._handlers.get(type(message))
|
|
if handler:
|
|
try:
|
|
handler(message)
|
|
except ApiError as e:
|
|
# Handle specific error cases
|
|
if e.status_code == 409:
|
|
return # Duplicate, ignore
|
|
elif e.status_code == 429:
|
|
raise RateLimitError(e)
|
|
else:
|
|
LOGGER.error(f"Failed to process message: {e}")
|
|
```
|
|
|
|
## Data Flow
|
|
|
|
### Complete Trace Creation Flow
|
|
|
|
Let's follow a trace creation from user code to backend in detail.
|
|
|
|
#### Step 1: User Creates Trace
|
|
|
|
```python
|
|
import opik
|
|
|
|
client = opik.Opik(project_name="my_project")
|
|
trace = client.trace(
|
|
name="my_trace",
|
|
input={"query": "test"},
|
|
metadata={"version": "1.0"}
|
|
)
|
|
```
|
|
|
|
#### Step 2: API Layer - Message Creation
|
|
|
|
```python
|
|
# In opik_client.py: Opik.trace()
|
|
|
|
def trace(self, name: str, input: dict, metadata: dict, **kwargs):
|
|
# 1. Generate ID if not provided
|
|
trace_id = kwargs.get("id") or id_helpers.generate_id()
|
|
|
|
# 2. Validate inputs
|
|
validation.validate_trace_parameters(name, input, metadata)
|
|
|
|
# 3. Create TraceData
|
|
trace_data = TraceData(
|
|
id=trace_id,
|
|
name=name,
|
|
input=input,
|
|
metadata=metadata,
|
|
project_name=self._project_name,
|
|
start_time=datetime_helpers.now()
|
|
)
|
|
|
|
# 4. Create message
|
|
message = CreateTraceMessage(
|
|
trace_id=trace_data.id,
|
|
name=trace_data.name,
|
|
input=trace_data.input,
|
|
metadata=trace_data.metadata,
|
|
project_name=trace_data.project_name,
|
|
start_time=trace_data.start_time,
|
|
# ... other fields
|
|
)
|
|
|
|
# 5. Send to streamer (non-blocking!)
|
|
self._streamer.put(message)
|
|
|
|
# 6. Return immediately
|
|
return trace_id
|
|
```
|
|
|
|
**Key Point**: User code continues immediately. Message processing happens asynchronously.
|
|
|
|
#### Step 3: Message Processing Layer - Routing
|
|
|
|
```python
|
|
# In streamer.py: Streamer.put()
|
|
|
|
def put(self, message: BaseMessage) -> None:
|
|
with self._lock:
|
|
# Check if draining
|
|
if self._drain:
|
|
return
|
|
|
|
# do embedded attachments pre-processing first (MUST ALWAYS BE DONE FIRST)
|
|
preprocessed_message = self._attachments_preprocessor.preprocess(
|
|
message
|
|
)
|
|
|
|
# do batching pre-processing third
|
|
preprocessed_message = self._batch_preprocessor.preprocess(
|
|
preprocessed_message
|
|
)
|
|
|
|
# Route to queue
|
|
if not self._message_queue.accept_put_without_discarding():
|
|
LOGGER.warning("Queue full, discarding oldest message")
|
|
self._message_queue.put(preprocessed_message)
|
|
```
|
|
|
|
**Decision Tree**:
|
|
```
|
|
Message arrives
|
|
│
|
|
├─► Supports batching? ──Yes──► BatchManager
|
|
├─► Has file upload? ───Yes──► FileUploadManager
|
|
└─► Default ─────────────────► MessageQueue
|
|
```
|
|
|
|
#### Step 4: Batching (Optional)
|
|
|
|
If batching is enabled, certain message types accumulate before sending.
|
|
|
|
**Messages that support batching** (current implementation):
|
|
- `CreateSpanMessage` → batched into `CreateSpansBatchMessage`
|
|
- `CreateTraceMessage` → batched into `CreateTraceBatchMessage`
|
|
- `AddTraceFeedbackScoresBatchMessage` → already a batch message
|
|
- `AddSpanFeedbackScoresBatchMessage` → already a batch message
|
|
- `CreateExperimentItemsBatchMessage` → already a batch message
|
|
|
|
**Messages that don't support batching** (sent individually):
|
|
- `UpdateSpanMessage` - Updates sent immediately
|
|
- `UpdateTraceMessage` - Updates sent immediately
|
|
- Other message types
|
|
|
|
```python
|
|
# In batch_manager.py
|
|
|
|
class BatchManager:
|
|
def process_message(self, message: BaseMessage) -> None:
|
|
# Find or create batcher for this message type
|
|
batcher = self._get_or_create_batcher(type(message))
|
|
|
|
# Add to batch
|
|
batcher.add(message)
|
|
|
|
# Check if should flush
|
|
if batcher.should_flush():
|
|
self._flush_batcher(batcher)
|
|
|
|
def _flush_batcher(self, batcher: BaseBatcher) -> None:
|
|
# Create batch message
|
|
batch_message = batcher.create_batch_message()
|
|
|
|
# Send to queue
|
|
self._message_queue.put(batch_message)
|
|
|
|
# Clear batcher
|
|
batcher.clear()
|
|
```
|
|
|
|
**Flush Triggers**:
|
|
1. **Time-based**: Periodic timer (e.g., every 1 second)
|
|
2. **Size-based**: Batch reaches size limit (e.g., 100 messages)
|
|
3. **Memory-based**: Batch reaches memory limit (e.g., 50MB)
|
|
4. **Manual**: User calls `flush()`
|
|
5. **Shutdown**: Manager stopping
|
|
|
|
#### Step 5: Queue and Consumer
|
|
|
|
```python
|
|
# Queue consumer pulls message
|
|
message = self._message_queue.get(timeout=0.1)
|
|
|
|
# Check delivery time (for rate limiting)
|
|
if message.delivery_time > time.monotonic():
|
|
# Re-queue for later
|
|
self._message_queue.put(message)
|
|
return
|
|
|
|
# Process message
|
|
self._message_processor.process(message)
|
|
```
|
|
|
|
#### Step 6: Message Processing - Handler Execution
|
|
|
|
```python
|
|
# In message_processors.py
|
|
|
|
def _process_create_trace_message(
|
|
self, message: CreateTraceMessage
|
|
) -> None:
|
|
# Map message to REST request
|
|
trace_write = trace_write.TraceWrite(
|
|
id=message.trace_id,
|
|
name=message.name,
|
|
input=message.input,
|
|
metadata=message.metadata,
|
|
project_name=message.project_name,
|
|
start_time=message.start_time.isoformat(),
|
|
# ... more fields
|
|
)
|
|
|
|
# Make REST API call
|
|
self._rest_client.traces.create_trace(
|
|
request=trace_write
|
|
)
|
|
```
|
|
|
|
#### Step 7: REST API Layer
|
|
|
|
```python
|
|
# In rest_api (auto-generated)
|
|
|
|
def create_trace(self, request: TraceWrite) -> TracePublic:
|
|
# Serialize request
|
|
json_data = request.dict(exclude_none=True)
|
|
|
|
# Make HTTP request
|
|
response = self._client.post(
|
|
"/v1/traces",
|
|
json=json_data,
|
|
headers={"Authorization": f"Bearer {self._api_key}"}
|
|
)
|
|
|
|
# Handle response
|
|
if response.status_code == 201:
|
|
return TracePublic.parse_obj(response.json())
|
|
else:
|
|
raise ApiError(response)
|
|
```
|
|
|
|
#### Step 8: Backend Storage
|
|
|
|
Backend receives request and stores in database:
|
|
- MySQL: Metadata, relationships
|
|
- ClickHouse: Time-series data, spans
|
|
|
|
### Decorator Data Flow
|
|
|
|
The `@opik.track` decorator provides automatic tracing. Here's the complete flow:
|
|
|
|
#### Initial Setup
|
|
|
|
```python
|
|
@opik.track
|
|
def my_function(x: int) -> int:
|
|
return x * 2
|
|
|
|
# Behind the scenes
|
|
_decorator = OpikTrackDecorator()
|
|
my_function = _decorator.track(my_function)
|
|
```
|
|
|
|
#### Execution Flow
|
|
|
|
```python
|
|
# User calls function
|
|
result = my_function(5)
|
|
```
|
|
|
|
**Step-by-Step Execution**:
|
|
|
|
```
|
|
1. Decorator intercepts call
|
|
↓
|
|
2. Check if tracing is active
|
|
↓
|
|
3. Extract function inputs
|
|
│
|
|
├─► Arguments: (5,)
|
|
├─► Keyword arguments: {}
|
|
└─► Combined: {"x": 5}
|
|
↓
|
|
4. Check for existing trace in context
|
|
│
|
|
├─► No trace? Create TraceData
|
|
│ │
|
|
│ ├─► Generate trace_id
|
|
│ ├─► Set start_time
|
|
│ ├─► Store in context: context_storage.set_trace_data()
|
|
│ │
|
|
│ Context state: trace_stack = [TraceData]
|
|
│
|
|
└─► Trace exists? Reuse
|
|
↓
|
|
5. Create SpanData
|
|
│
|
|
├─► Generate span_id
|
|
├─► Get trace_id from context
|
|
├─► Check for parent span
|
|
│ │
|
|
│ ├─► Parent exists? Set parent_span_id
|
|
│ └─► No parent? parent_span_id = None
|
|
│
|
|
├─► Capture input: {"x": 5}
|
|
├─► Set start_time
|
|
├─► Set type, name, metadata, tags
|
|
│
|
|
Context state: span_stack = [..., SpanData]
|
|
↓
|
|
6. Push span to context
|
|
context_storage.add_span_data(span_data)
|
|
↓
|
|
7. Execute wrapped function
|
|
│
|
|
├─► try:
|
|
│ result = my_function(5) # Original function
|
|
│ except Exception as e:
|
|
│ error_info = collect_error_info(e)
|
|
│ span_data.error_info = error_info
|
|
│ raise # Re-raise to user
|
|
│
|
|
└─► Returns: 10
|
|
↓
|
|
8. Capture output
|
|
│
|
|
├─► If capture_output=True:
|
|
│ │
|
|
│ ├─► Output is dict? Use as-is
|
|
│ └─► Not dict? Wrap: {"output": 10}
|
|
│
|
|
└─► span_data.output = {"output": 10}
|
|
↓
|
|
9. Set end_time
|
|
span_data.end_time = datetime_helpers.now()
|
|
↓
|
|
10. Pop span from context
|
|
span_data = context_storage.pop_span_data()
|
|
|
|
Context state: span_stack = [...]
|
|
↓
|
|
11. Send span to backend
|
|
│
|
|
├─► Create CreateSpanMessage from span_data
|
|
├─► streamer.put(message)
|
|
│
|
|
[Async processing begins]
|
|
↓
|
|
12. Check if top-level function
|
|
│
|
|
├─► span_stack is empty?
|
|
│ │
|
|
│ ├─► Yes: Also send trace
|
|
│ │ │
|
|
│ │ ├─► trace_data = context_storage.pop_trace_data()
|
|
│ │ ├─► Set trace end_time
|
|
│ │ ├─► Create CreateTraceMessage
|
|
│ │ └─► streamer.put(message)
|
|
│ │
|
|
│ Context state: trace_stack = [], span_stack = []
|
|
│
|
|
└─► No: Leave trace in context (more spans coming)
|
|
↓
|
|
13. Return result to user
|
|
return 10
|
|
```
|
|
|
|
#### Nested Function Flow
|
|
|
|
```python
|
|
@opik.track
|
|
def outer(x):
|
|
result = inner(x)
|
|
return result * 2
|
|
|
|
@opik.track
|
|
def inner(x):
|
|
return x + 1
|
|
|
|
# Call
|
|
outer(5)
|
|
```
|
|
|
|
**Context State Timeline**:
|
|
|
|
```
|
|
Time │ Action │ Trace Stack │ Span Stack
|
|
──────┼─────────────────────────┼────────────────┼─────────────────
|
|
T0 │ outer() called │ [] │ []
|
|
T1 │ Create trace │ [Trace-A] │ []
|
|
T2 │ Create span-outer │ [Trace-A] │ [Span-1]
|
|
T3 │ inner() called │ [Trace-A] │ [Span-1]
|
|
T4 │ Reuse trace │ [Trace-A] │ [Span-1]
|
|
T5 │ Create span-inner │ [Trace-A] │ [Span-1, Span-2]
|
|
│ (parent=Span-1) │ │
|
|
T6 │ inner() executes │ [Trace-A] │ [Span-1, Span-2]
|
|
T7 │ inner() returns │ [Trace-A] │ [Span-1, Span-2]
|
|
T8 │ Pop span-inner │ [Trace-A] │ [Span-1]
|
|
T9 │ Send Span-2 message │ [Trace-A] │ [Span-1]
|
|
T10 │ outer() continues │ [Trace-A] │ [Span-1]
|
|
T11 │ outer() returns │ [Trace-A] │ [Span-1]
|
|
T12 │ Pop span-outer │ [Trace-A] │ []
|
|
T13 │ Send Span-1 message │ [Trace-A] │ []
|
|
T14 │ Stack empty! Send trace │ [] │ []
|
|
T15 │ Pop trace, send message │ [] │ []
|
|
```
|
|
|
|
**Resulting Tree**:
|
|
```
|
|
Trace-A
|
|
└─ Span-1 (outer)
|
|
└─ Span-2 (inner)
|
|
```
|
|
|
|
## Message Processing Deep Dive
|
|
|
|
### Message Types
|
|
|
|
All messages inherit from `BaseMessage`:
|
|
|
|
```python
|
|
class BaseMessage:
|
|
delivery_time: float = 0.0 # For rate limiting
|
|
|
|
def dict(self) -> Dict[str, Any]:
|
|
"""Convert to dictionary"""
|
|
|
|
# Core message types
|
|
class CreateTraceMessage(BaseMessage):
|
|
trace_id: str
|
|
name: Optional[str]
|
|
input: Optional[Dict]
|
|
output: Optional[Dict]
|
|
metadata: Optional[Dict]
|
|
tags: Optional[List[str]]
|
|
start_time: datetime
|
|
end_time: Optional[datetime]
|
|
# ... more fields
|
|
|
|
class CreateSpanMessage(BaseMessage):
|
|
span_id: str
|
|
trace_id: str
|
|
parent_span_id: Optional[str]
|
|
name: Optional[str]
|
|
type: str
|
|
input: Optional[Dict]
|
|
output: Optional[Dict]
|
|
usage: Optional[Dict]
|
|
model: Optional[str]
|
|
provider: Optional[str]
|
|
# ... more fields
|
|
|
|
class UpdateSpanMessage(BaseMessage):
|
|
span_id: str
|
|
# Only fields to update
|
|
|
|
class UpdateTraceMessage(BaseMessage):
|
|
trace_id: str
|
|
# Only fields to update
|
|
|
|
# Batch message types
|
|
class CreateSpansBatchMessage(BaseMessage):
|
|
spans: List[CreateSpanMessage]
|
|
|
|
class CreateTraceBatchMessage(BaseMessage):
|
|
traces: List[CreateTraceMessage]
|
|
|
|
# Feedback messages
|
|
class AddTraceFeedbackScoresBatchMessage(BaseMessage):
|
|
feedback_scores: List[FeedbackScoreDict]
|
|
|
|
class AddSpanFeedbackScoresBatchMessage(BaseMessage):
|
|
feedback_scores: List[FeedbackScoreDict]
|
|
|
|
# Experiment item messages
|
|
class ExperimentItemMessage(BaseMessage):
|
|
id: str
|
|
experiment_id: str
|
|
trace_id: str
|
|
dataset_item_id: str
|
|
|
|
class CreateExperimentItemsBatchMessage(BaseMessage):
|
|
batch: List[ExperimentItemMessage]
|
|
```
|
|
|
|
### Message Routing Logic
|
|
|
|
```python
|
|
def put(self, message: BaseMessage) -> None:
|
|
"""Route message to appropriate handler"""
|
|
|
|
# 1. Check if draining
|
|
if self._drain:
|
|
return # Drop message (shutdown in progress)
|
|
|
|
# 2. Check batching support
|
|
if (
|
|
self._batch_manager is not None
|
|
and self._batch_manager.message_supports_batching(message)
|
|
):
|
|
# Messages that support batching:
|
|
# - CreateSpanMessage
|
|
# - CreateTraceMessage
|
|
# - Feedback score messages (always batched)
|
|
self._batch_manager.process_message(message)
|
|
return
|
|
|
|
# 3. Check file upload support
|
|
if base_upload_manager.message_supports_upload(message):
|
|
# Messages with attachments
|
|
# - Uploaded to S3 first
|
|
# - Then regular message sent with S3 URLs
|
|
self._upload_preprocessor.upload(message)
|
|
return
|
|
|
|
# 4. Default: Add to queue
|
|
if not self._message_queue.accept_put_without_discarding():
|
|
# Queue is full
|
|
LOGGER.warning("Queue full, discarding oldest message")
|
|
|
|
self._message_queue.put(message)
|
|
```
|
|
|
|
### Attachment Extraction Preprocessing
|
|
|
|
Before messages reach the queue or batch manager, the SDK can optionally preprocess them to extract embedded base64-encoded attachments (images, PDFs, etc.) from trace/span input, output, and metadata fields.
|
|
|
|
#### Preprocessing Pipeline
|
|
|
|
```python
|
|
# In streamer.py: Streamer.__init__()
|
|
|
|
def __init__(self, ...):
|
|
# Create preprocessing pipeline
|
|
self._message_preprocessors = []
|
|
|
|
# 1. Attachments preprocessor (conditionally wraps messages)
|
|
attachments_preprocessor = AttachmentsPreprocessor(enabled=True)
|
|
self._message_preprocessors.append(attachments_preprocessor)
|
|
|
|
# 2. Batching preprocessor (groups batchable messages)
|
|
batching_preprocessor = BatchingPreprocessor(...)
|
|
self._message_preprocessors.append(batching_preprocessor)
|
|
|
|
# Messages flow through preprocessors before routing
|
|
def put(self, message: BaseMessage) -> None:
|
|
# Apply preprocessors in order
|
|
for preprocessor in self._message_preprocessors:
|
|
message = preprocessor.preprocess(message)
|
|
|
|
# Then route to queue/batch/upload
|
|
self._route_message(message)
|
|
```
|
|
|
|
#### AttachmentsPreprocessor: Selective Wrapping
|
|
|
|
The `AttachmentsPreprocessor` decides which messages need attachment extraction:
|
|
|
|
```python
|
|
class AttachmentsPreprocessor(MessagePreprocessor):
|
|
def preprocess(self, message: BaseMessage) -> BaseMessage:
|
|
"""
|
|
Wraps messages that need attachment extraction in AttachmentSupportingMessage.
|
|
|
|
Only wraps if:
|
|
1. Update messages (UpdateSpanMessage, UpdateTraceMessage) - always process
|
|
2. Create messages with end_time set - final data, ready to extract
|
|
|
|
Does NOT wrap:
|
|
- Create messages without end_time - in-progress operations
|
|
"""
|
|
if _has_potential_content_with_attachments(message):
|
|
return AttachmentSupportingMessage(message)
|
|
return message
|
|
|
|
def _has_potential_content_with_attachments(message: BaseMessage) -> bool:
|
|
# Check if it's an Update message - always process these
|
|
if isinstance(message, (UpdateSpanMessage, UpdateTraceMessage)):
|
|
return _message_has_field_of_interest_set(message)
|
|
|
|
# Check if it's a Create message with end_time set - only process these
|
|
if isinstance(message, (CreateSpanMessage, CreateTraceMessage)):
|
|
if message.end_time is not None:
|
|
return _message_has_field_of_interest_set(message)
|
|
return False
|
|
|
|
return False
|
|
|
|
def _message_has_field_of_interest_set(message) -> bool:
|
|
"""Check if message has input, output, or metadata fields set"""
|
|
return (
|
|
message.input is not None
|
|
or message.output is not None
|
|
or message.metadata is not None
|
|
)
|
|
```
|
|
|
|
**Key Design Decision**: Why skip Create messages without `end_time`?
|
|
|
|
```
|
|
Trace/Span Lifecycle:
|
|
│
|
|
├─► Create (no end_time) ──► In-progress operation
|
|
│ │ - May be updated multiple times
|
|
│ │ - Extracting attachments now is wasteful
|
|
│ │ - Will extract on final update anyway
|
|
│ │
|
|
│ └─► Update (sets end_time) ──► Completed operation
|
|
│ │ - Contains final data
|
|
│ │ - Extract attachments now ✓
|
|
│ │
|
|
│ └─► Backend storage
|
|
│
|
|
└─► Create (with end_time) ──► Synchronous/completed operation
|
|
│ - Contains final data upfront
|
|
│ - Extract attachments now ✓
|
|
│
|
|
└─► Backend storage
|
|
```
|
|
|
|
**Performance Impact**:
|
|
- For 1000 concurrent traces: 50% reduction in attachment processing
|
|
- Avoids duplicate extraction (create + update)
|
|
- Only processes messages with final data
|
|
|
|
#### AttachmentSupportingMessage Wrapper
|
|
|
|
```python
|
|
class AttachmentSupportingMessage(BaseMessage):
|
|
"""
|
|
Wrapper that signals a message needs attachment extraction.
|
|
|
|
The wrapped message is processed by AttachmentsExtractionProcessor
|
|
before being sent to backend.
|
|
"""
|
|
original_message: Union[
|
|
CreateSpanMessage,
|
|
UpdateSpanMessage,
|
|
CreateTraceMessage,
|
|
UpdateTraceMessage
|
|
]
|
|
```
|
|
|
|
#### Attachment Extraction Flow
|
|
|
|
```
|
|
1. Message arrives at Streamer
|
|
│
|
|
▼
|
|
2. AttachmentsPreprocessor.preprocess()
|
|
│
|
|
├─► Should extract? (Update or Create with end_time)
|
|
│ │
|
|
│ ├─► Yes: Wrap in AttachmentSupportingMessage
|
|
│ │ │
|
|
│ │ └─► Route to AttachmentsExtractionProcessor
|
|
│ │ │
|
|
│ │ ├─► Extract base64 attachments from input/output/metadata
|
|
│ │ │ - Handles nested dictionaries and lists
|
|
│ │ │ - Supports PNG, JPEG, PDF, GIF, WebP, SVG, JSON
|
|
│ │ │ - Replaces base64 with placeholder: [filename.png]
|
|
│ │ │
|
|
│ │ ├─► Upload attachments to S3
|
|
│ │ │
|
|
│ │ └─► Forward original message (now sanitized) to queue
|
|
│ │
|
|
│ └─► No: Pass through unchanged
|
|
│ │
|
|
│ └─► Route directly to queue/batch/upload
|
|
│
|
|
▼
|
|
3. Message continues through normal pipeline
|
|
```
|
|
|
|
#### AttachmentsExtractor: Nested Structure Support
|
|
|
|
The extractor recursively processes nested data structures:
|
|
|
|
```python
|
|
class AttachmentsExtractor:
|
|
def extract_and_replace(
|
|
self,
|
|
data: Dict[str, Any],
|
|
entity_type: Literal["span", "trace"],
|
|
entity_id: str,
|
|
project_name: str,
|
|
context: Literal["input", "output", "metadata"],
|
|
) -> List[AttachmentWithContext]:
|
|
"""
|
|
Extract attachments from data and replace with placeholders.
|
|
|
|
Handles:
|
|
- Simple strings: {"image": "data:image/png;base64,..."}
|
|
- Nested dicts: {"user": {"avatar": "data:image/png;base64,..."}}
|
|
- Lists: {"images": ["data:image/png;base64,...", "data:image/jpeg;base64,..."]}
|
|
- Mixed: {"messages": [{"role": "user", "content": [{"image": "data:..."}]}]}
|
|
"""
|
|
attachments = []
|
|
for key, value in data.items():
|
|
result = self._try_extract_attachments(value, context)
|
|
if result.attachments:
|
|
data[key] = result.sanitized_data
|
|
attachments.extend(...)
|
|
return attachments
|
|
|
|
def _try_extract_attachments(self, data: Any, context: str) -> ExtractionResult:
|
|
"""Recursively extract from any data type"""
|
|
if isinstance(data, str):
|
|
return self._extract_from_string(data, context)
|
|
elif isinstance(data, dict):
|
|
return self._extract_from_dict(data, context)
|
|
elif isinstance(data, list):
|
|
return self._extract_from_list(data, context)
|
|
else:
|
|
# int, bool, None, etc. - return as-is
|
|
return ExtractionResult(attachments=[], sanitized_data=data)
|
|
```
|
|
|
|
**Example**: Nested structure extraction
|
|
|
|
```python
|
|
# Input
|
|
trace_input = {
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "What's in this image?"},
|
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0K..."}}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
# After extraction
|
|
trace_input = {
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "What's in this image?"},
|
|
{"type": "image_url", "image_url": {"url": "[input-attachment-abc123.png]"}}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
# Extracted attachment uploaded to S3
|
|
# Attachment: {file_name: "input-attachment-abc123.png", content_type: "image/png", ...}
|
|
```
|
|
|
|
**Supported Formats**:
|
|
- Images: PNG, JPEG, GIF, WebP, SVG
|
|
- Documents: PDF, JSON
|
|
- Pattern: `data:<mime-type>;base64,<base64-data>`
|
|
|
|
#### Integration with Message Pipeline
|
|
|
|
```
|
|
User Code
|
|
│
|
|
▼
|
|
CreateSpanMessage(
|
|
input={"image": "data:image/png;base64,..."},
|
|
end_time=now() # ← Key: end_time is set
|
|
)
|
|
│
|
|
▼
|
|
Streamer.put()
|
|
│
|
|
├─► AttachmentsPreprocessor
|
|
│ │
|
|
│ └─► Has end_time? YES → Wrap in AttachmentSupportingMessage
|
|
│
|
|
▼
|
|
Route to AttachmentsExtractionProcessor
|
|
│
|
|
├─► Extract attachments
|
|
│ - Find base64 data in input
|
|
│ - Decode and identify type (PNG)
|
|
│ - Save to temporary file
|
|
│ - Replace with placeholder
|
|
│
|
|
├─► Upload to S3
|
|
│ - CreateAttachmentMessage → S3
|
|
│
|
|
└─► Forward sanitized CreateSpanMessage
|
|
- input={"image": "[input-attachment-123.png]"}
|
|
│
|
|
▼
|
|
BatchManager (or Queue)
|
|
│
|
|
▼
|
|
Backend storage
|
|
```
|
|
|
|
### Consumer Processing Loop
|
|
|
|
```python
|
|
class QueueConsumer(threading.Thread):
|
|
def run(self) -> None:
|
|
"""Main worker loop"""
|
|
while not self._processing_stopped:
|
|
self._loop()
|
|
|
|
def _loop(self) -> None:
|
|
"""Process one message"""
|
|
|
|
# 1. Check rate limiting
|
|
now = time.monotonic()
|
|
if now < self.next_message_time:
|
|
self.idling = False
|
|
time.sleep(SLEEP_BETWEEN_LOOP_ITERATIONS)
|
|
return
|
|
|
|
# 2. Get message from queue
|
|
try:
|
|
self.idling = True
|
|
message = self._message_queue.get(
|
|
timeout=SLEEP_BETWEEN_LOOP_ITERATIONS
|
|
)
|
|
self.idling = False
|
|
|
|
if message is None:
|
|
return
|
|
|
|
# 3. Check delivery time
|
|
if message.delivery_time <= now:
|
|
# Ready to process
|
|
self._message_processor.process(message)
|
|
else:
|
|
# Not ready yet, re-queue
|
|
self._push_message_back(message)
|
|
|
|
except Empty:
|
|
time.sleep(SLEEP_BETWEEN_LOOP_ITERATIONS)
|
|
|
|
except OpikCloudRequestsRateLimited as e:
|
|
# 4. Handle rate limiting
|
|
LOGGER.info(
|
|
"Rate limited, retrying in %s seconds",
|
|
e.retry_after
|
|
)
|
|
|
|
# Update next processing time
|
|
self.next_message_time = now + e.retry_after
|
|
|
|
# Re-queue message with delay
|
|
if message is not None:
|
|
message.delivery_time = self.next_message_time
|
|
self._push_message_back(message)
|
|
|
|
except Exception as ex:
|
|
LOGGER.error("Unexpected error: %s", ex, exc_info=ex)
|
|
```
|
|
|
|
### Error Handling in Message Processing
|
|
|
|
```python
|
|
def process(self, message: BaseMessage) -> None:
|
|
"""Process message with comprehensive error handling"""
|
|
|
|
message_type = type(message)
|
|
handler = self._handlers.get(message_type)
|
|
|
|
if handler is None:
|
|
LOGGER.debug("Unknown message type: %s", message_type.__name__)
|
|
return
|
|
|
|
try:
|
|
# Execute handler
|
|
handler(message)
|
|
|
|
except ApiError as exception:
|
|
# 1. Handle duplicate requests
|
|
if exception.status_code == 409:
|
|
# Retry mechanism sent duplicate, ignore
|
|
return
|
|
|
|
# 2. Handle rate limiting
|
|
elif exception.status_code == 429:
|
|
# Extract retry-after from headers
|
|
if exception.headers is not None:
|
|
rate_limiter = rate_limit.parse_rate_limit(exception.headers)
|
|
if rate_limiter is not None:
|
|
raise OpikCloudRequestsRateLimited(
|
|
headers=exception.headers,
|
|
retry_after=rate_limiter.retry_after()
|
|
)
|
|
|
|
# 3. Other API errors
|
|
LOGGER.error(
|
|
"Failed to process %s: %s",
|
|
message_type.__name__,
|
|
str(exception),
|
|
extra={"error_tracking_extra": error_info}
|
|
)
|
|
|
|
except RetryError as retry_error:
|
|
# 4. Retry exhausted
|
|
cause = retry_error.last_attempt.exception()
|
|
LOGGER.error(
|
|
"Retries exhausted for %s: %s",
|
|
message_type.__name__,
|
|
cause
|
|
)
|
|
LOGGER.warning("Check Opik configuration")
|
|
|
|
except ValidationError as validation_error:
|
|
# 5. Data validation failed
|
|
LOGGER.error(
|
|
"Validation failed for %s: %s",
|
|
message_type.__name__,
|
|
validation_error
|
|
)
|
|
```
|
|
|
|
## Batching System
|
|
|
|
### Why Batching?
|
|
|
|
Batching reduces overhead by:
|
|
1. **Fewer HTTP requests**: 100 spans → 1 request
|
|
2. **Lower latency**: Amortized network cost
|
|
3. **Better throughput**: More efficient use of connections
|
|
4. **Reduced backend load**: Fewer requests to process
|
|
|
|
### Batch Manager Architecture
|
|
|
|
```python
|
|
class BatchManager:
|
|
def __init__(self, message_queue: MessageQueue):
|
|
self._message_queue = message_queue
|
|
self._batchers: Dict[Type, BaseBatcher] = {}
|
|
self._lock = threading.RLock()
|
|
|
|
# Timer for periodic flushing
|
|
self._timer: Optional[threading.Timer] = None
|
|
self._flush_interval = 1.0 # seconds
|
|
|
|
def start(self) -> None:
|
|
"""Start periodic flushing"""
|
|
self._schedule_flush()
|
|
|
|
def _schedule_flush(self) -> None:
|
|
"""Schedule next flush"""
|
|
self._timer = threading.Timer(
|
|
self._flush_interval,
|
|
self._periodic_flush
|
|
)
|
|
self._timer.daemon = True
|
|
self._timer.start()
|
|
|
|
def _periodic_flush(self) -> None:
|
|
"""Flush all batchers periodically"""
|
|
self.flush()
|
|
if not self._stopped:
|
|
self._schedule_flush()
|
|
```
|
|
|
|
### Batcher Types
|
|
|
|
#### Spans Batcher
|
|
|
|
```python
|
|
class SpansBatcher(BaseBatcher):
|
|
def __init__(self, max_batch_size: int = 100, max_memory_mb: int = 50):
|
|
self._messages: List[CreateSpanMessage] = []
|
|
self._max_batch_size = max_batch_size
|
|
self._max_memory_bytes = max_memory_mb * 1024 * 1024
|
|
self._current_memory = 0
|
|
|
|
def add(self, message: CreateSpanMessage) -> None:
|
|
"""Add span to batch"""
|
|
self._messages.append(message)
|
|
self._current_memory += self._estimate_size(message)
|
|
|
|
def should_flush(self) -> bool:
|
|
"""Check if batch should be flushed"""
|
|
return (
|
|
len(self._messages) >= self._max_batch_size
|
|
or self._current_memory >= self._max_memory_bytes
|
|
)
|
|
|
|
def create_batch_message(self) -> CreateSpansBatchMessage:
|
|
"""Create batch message from accumulated spans"""
|
|
return CreateSpansBatchMessage(
|
|
spans=self._messages.copy()
|
|
)
|
|
|
|
def clear(self) -> None:
|
|
"""Clear batch"""
|
|
self._messages.clear()
|
|
self._current_memory = 0
|
|
|
|
def _estimate_size(self, message: CreateSpanMessage) -> int:
|
|
"""Estimate message size in bytes"""
|
|
# Rough estimation based on serialized size
|
|
data = message.dict()
|
|
return len(json.dumps(data).encode('utf-8'))
|
|
```
|
|
|
|
### Batch Processing Flow
|
|
|
|
```
|
|
Individual messages arrive
|
|
│
|
|
▼
|
|
┌────────────────────────────────────┐
|
|
│ BatchManager.process_message() │
|
|
│ │
|
|
│ 1. Find batcher for message type │
|
|
│ 2. Add to batch │
|
|
│ 3. Check flush conditions │
|
|
└────────┬───────────────────────────┘
|
|
│
|
|
▼
|
|
Should flush?
|
|
│
|
|
┌────┴────┐
|
|
│ │
|
|
No Yes
|
|
│ │
|
|
│ ▼
|
|
│ ┌──────────────────────────┐
|
|
│ │ Create batch message │
|
|
│ │ (e.g., 100 spans) │
|
|
│ └───┬──────────────────────┘
|
|
│ │
|
|
│ ▼
|
|
│ ┌──────────────────────────┐
|
|
│ │ Add to MessageQueue │
|
|
│ └───┬──────────────────────┘
|
|
│ │
|
|
│ ▼
|
|
│ ┌──────────────────────────┐
|
|
│ │ Clear batcher │
|
|
│ └──────────────────────────┘
|
|
│
|
|
└──► Continue accumulating
|
|
```
|
|
|
|
### Flush Triggers
|
|
|
|
#### 1. Time-Based Flush
|
|
|
|
```python
|
|
def _periodic_flush(self) -> None:
|
|
"""Called every flush_interval seconds"""
|
|
with self._lock:
|
|
for batcher in self._batchers.values():
|
|
if not batcher.is_empty():
|
|
batch_message = batcher.create_batch_message()
|
|
self._message_queue.put(batch_message)
|
|
batcher.clear()
|
|
```
|
|
|
|
**Example**: Every 1 second, flush all non-empty batchers.
|
|
|
|
#### 2. Size-Based Flush
|
|
|
|
```python
|
|
def process_message(self, message: BaseMessage) -> None:
|
|
"""Add message to batch, flush if size limit reached"""
|
|
batcher = self._get_or_create_batcher(type(message))
|
|
batcher.add(message)
|
|
|
|
if batcher.should_flush():
|
|
batch_message = batcher.create_batch_message()
|
|
self._message_queue.put(batch_message)
|
|
batcher.clear()
|
|
```
|
|
|
|
**Example**: Batch reaches 100 messages.
|
|
|
|
#### 3. Memory-Based Flush
|
|
|
|
```python
|
|
def should_flush(self) -> bool:
|
|
"""Check memory threshold"""
|
|
return (
|
|
len(self._messages) >= self._max_batch_size
|
|
or self._current_memory >= self._max_memory_bytes
|
|
)
|
|
```
|
|
|
|
**Example**: Batch reaches 50MB.
|
|
|
|
#### 4. Manual Flush
|
|
|
|
```python
|
|
def flush(self) -> None:
|
|
"""User-triggered flush"""
|
|
client.flush(timeout=30)
|
|
|
|
# Internally:
|
|
# 1. Flush all batchers
|
|
batch_manager.flush()
|
|
|
|
# 2. Wait for queue to empty
|
|
while not message_queue.empty() and not timeout:
|
|
time.sleep(0.1)
|
|
```
|
|
|
|
#### 5. Shutdown Flush
|
|
|
|
```python
|
|
def stop(self) -> None:
|
|
"""Flush on shutdown"""
|
|
self._stopped = True
|
|
|
|
# Cancel timer
|
|
if self._timer:
|
|
self._timer.cancel()
|
|
|
|
# Flush all remaining messages
|
|
self.flush()
|
|
```
|
|
|
|
### Batch Message Processing
|
|
|
|
```python
|
|
def _process_create_spans_batch_message(
|
|
self, message: CreateSpansBatchMessage
|
|
) -> None:
|
|
"""Process batch of spans"""
|
|
|
|
# Split into chunks if too large
|
|
chunks = sequence_splitter.split_into_chunks(
|
|
message.spans,
|
|
max_chunk_size=self._batch_memory_limit_mb
|
|
)
|
|
|
|
for chunk in chunks:
|
|
# Convert to REST request format
|
|
span_writes = [
|
|
span_write.SpanWrite.from_message(span_msg)
|
|
for span_msg in chunk
|
|
]
|
|
|
|
# Make single API call for all spans
|
|
self._rest_client.spans.create_spans_batch(
|
|
request=span_writes
|
|
)
|
|
```
|
|
|
|
**Benefits**:
|
|
- 100 individual spans → 1 API call
|
|
- Reduced network overhead
|
|
- Better backend performance
|
|
|
|
## Observability
|
|
|
|
### Logging
|
|
|
|
The SDK uses Python's standard logging with structured extra data.
|
|
|
|
#### Logger Configuration
|
|
|
|
```python
|
|
import logging
|
|
|
|
# Module-level loggers
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
# Logging hierarchy
|
|
opik # Root logger
|
|
├── opik.api_objects # API objects
|
|
├── opik.decorator # Decorator logic
|
|
├── opik.message_processing # Message processing
|
|
│ ├── streamer
|
|
│ ├── message_processors
|
|
│ └── batching
|
|
└── opik.evaluation # Evaluation
|
|
```
|
|
|
|
#### Log Levels
|
|
|
|
- **DEBUG**: Detailed information for debugging
|
|
- **INFO**: General informational messages
|
|
- **WARNING**: Warnings (queue full, rate limits)
|
|
- **ERROR**: Error conditions (API failures, processing errors)
|
|
|
|
#### Example Log Messages
|
|
|
|
```python
|
|
# Queue full warning
|
|
LOGGER.warning(
|
|
"Queue size limit reached. Message added, oldest discarded."
|
|
)
|
|
|
|
# Rate limiting info
|
|
LOGGER.info(
|
|
"Rate limited, retrying in %s seconds, queue size: %d",
|
|
retry_after,
|
|
queue_size
|
|
)
|
|
|
|
# Processing error
|
|
LOGGER.error(
|
|
"Failed to process %s: %s",
|
|
message_type.__name__,
|
|
str(exception),
|
|
extra={"error_tracking_extra": error_info}
|
|
)
|
|
|
|
# Configuration warning
|
|
LOGGER.warning(
|
|
"Opik may not be configured correctly. "
|
|
"Run 'opik configure' to set up."
|
|
)
|
|
```
|
|
|
|
### Error Tracking
|
|
|
|
The SDK integrates with Sentry for error tracking (opt-in, randomized).
|
|
|
|
#### Error Filtering
|
|
|
|
```python
|
|
# Only track specific errors
|
|
class ErrorLevelCountFilter:
|
|
"""Only send ERROR and CRITICAL level events"""
|
|
def __call__(self, event, hint):
|
|
return event['level'] in ['error', 'fatal']
|
|
|
|
class ResponseStatusCodeFilter:
|
|
"""Filter out expected HTTP errors"""
|
|
def __call__(self, event, hint):
|
|
# Don't track 409 (Conflict - duplicate)
|
|
# Don't track 429 (Rate Limit - expected)
|
|
status_code = extract_status_code(event)
|
|
return status_code not in [409, 429]
|
|
```
|
|
|
|
#### Error Context
|
|
|
|
```python
|
|
def _generate_error_tracking_extra(
|
|
exception: Exception,
|
|
message: BaseMessage
|
|
) -> Dict[str, Any]:
|
|
"""Generate structured error context"""
|
|
return {
|
|
"message_type": type(message).__name__,
|
|
"exception_type": type(exception).__name__,
|
|
"status_code": getattr(exception, 'status_code', None),
|
|
"message_data": message.dict(),
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
```
|
|
|
|
### Performance Metrics
|
|
|
|
#### Internal Metrics
|
|
|
|
The SDK tracks internal performance:
|
|
|
|
```python
|
|
# Message queue metrics
|
|
queue_size = message_queue.qsize()
|
|
queue_full_events = metric_counter["queue_full"]
|
|
|
|
# Batch metrics
|
|
batch_size = len(batcher._messages)
|
|
batch_memory = batcher._current_memory
|
|
batches_flushed = metric_counter["batches_flushed"]
|
|
|
|
# Consumer metrics
|
|
consumer_idle = consumer.idling
|
|
messages_processed = metric_counter["messages_processed"]
|
|
processing_errors = metric_counter["processing_errors"]
|
|
```
|
|
|
|
#### User-Facing Metrics
|
|
|
|
```python
|
|
# Flush status
|
|
success = client.flush(timeout=30)
|
|
if not success:
|
|
LOGGER.warning("Flush timeout, some messages may not be sent")
|
|
|
|
# Queue info (logged automatically)
|
|
LOGGER.info("Queue size: %d messages", queue_size)
|
|
```
|
|
|
|
### Health Checks
|
|
|
|
```python
|
|
from opik.healthcheck import check_health
|
|
|
|
# Check SDK health
|
|
result = check_health()
|
|
|
|
# Returns:
|
|
{
|
|
"backend_reachable": True,
|
|
"authentication_valid": True,
|
|
"project_exists": True,
|
|
"version": "0.1.0",
|
|
"configuration": {...}
|
|
}
|
|
```
|
|
|
|
## Performance Considerations
|
|
|
|
### Memory Usage
|
|
|
|
#### Message Queue
|
|
|
|
- **Default**: Unlimited queue size
|
|
- **With limit**: Oldest messages discarded when full
|
|
- **Trade-off**: Memory vs. data loss
|
|
|
|
```python
|
|
# Unlimited (default)
|
|
client = opik.Opik() # No queue limit
|
|
|
|
# Limited (for memory-constrained environments)
|
|
# Set via config, not directly exposed
|
|
```
|
|
|
|
#### Batching
|
|
|
|
- **Memory limit**: 50MB per batch by default
|
|
- **Monitoring**: Estimated message sizes tracked
|
|
- **Auto-flush**: Triggers before memory limit
|
|
|
|
### CPU Usage
|
|
|
|
#### Background Threads
|
|
|
|
- **Queue consumers**: N threads (default: 1)
|
|
- **Batch manager**: 1 timer thread
|
|
- **File upload**: M threads (default: 5)
|
|
|
|
**Total**: N + M + 1 background threads
|
|
|
|
#### Processing Overhead
|
|
|
|
- **Message creation**: Minimal (dictionary construction)
|
|
- **Serialization**: Lazy (only when sending)
|
|
- **Batching**: Low overhead (list append)
|
|
|
|
### Network Usage
|
|
|
|
#### Without Batching
|
|
|
|
- **Requests**: 1 per trace/span
|
|
- **Typical**: 100-1000 requests/second for busy app
|
|
|
|
#### With Batching
|
|
|
|
- **Requests**: 1 per batch
|
|
- **Batch size**: 100 spans per request
|
|
- **Reduction**: 100x fewer requests
|
|
|
|
#### Connection Pooling
|
|
|
|
- **HTTP client**: Reuses connections
|
|
- **Max connections**: Configurable (default: 10)
|
|
|
|
### Latency Impact
|
|
|
|
#### User Code
|
|
|
|
```python
|
|
# Non-blocking call
|
|
trace = client.trace(...) # Returns trace object immediately (~1μs)
|
|
|
|
# Decorator overhead
|
|
@opik.track
|
|
def my_function():
|
|
pass # Overhead: ~10-100μs per call
|
|
```
|
|
|
|
#### Backend Communication
|
|
|
|
```python
|
|
# Async processing
|
|
# User code continues immediately
|
|
# Backend calls happen in background
|
|
|
|
# Flush latency
|
|
client.flush(timeout=30) # Waits for all messages
|
|
```
|
|
|
|
### Best Practices
|
|
|
|
1. **Use batching** for high-throughput applications
|
|
2. **Call flush()** before application exit
|
|
3. **Monitor queue size** in logs
|
|
4. **Configure timeout** for flush operations
|
|
5. **Use decorators** for automatic tracking (lower overhead)
|
|
6. **Avoid excessive metadata** (keep traces/spans lightweight)
|
|
|
|
### Troubleshooting Performance
|
|
|
|
#### High Memory Usage
|
|
|
|
```python
|
|
# Check message queue size
|
|
# If growing unbounded:
|
|
# 1. Check backend connectivity
|
|
# 2. Check rate limiting
|
|
# 3. Reduce tracing frequency
|
|
```
|
|
|
|
#### Slow Flush
|
|
|
|
```python
|
|
# If flush() takes too long:
|
|
# 1. Check queue size (too many pending messages)
|
|
# 2. Check network latency
|
|
# 3. Increase timeout
|
|
client.flush(timeout=60) # Increase timeout
|
|
```
|
|
|
|
#### Message Loss
|
|
|
|
```python
|
|
# If messages not appearing:
|
|
# 1. Check backend connectivity
|
|
# 2. Verify authentication
|
|
# 3. Check for ERROR logs
|
|
# 4. Call flush() before exit
|
|
```
|
|
|
|
## Summary
|
|
|
|
The Opik Python SDK provides:
|
|
|
|
1. **Simple API**: High-level methods and decorators
|
|
2. **Non-blocking**: Asynchronous message processing
|
|
3. **Efficient**: Batching and connection pooling
|
|
4. **Reliable**: Retry logic and error handling
|
|
5. **Observable**: Comprehensive logging and monitoring
|
|
|
|
For more information, see:
|
|
- [Integrations](INTEGRATIONS.md) - LLM framework integrations
|
|
- [Evaluation](EVALUATION.md) - Evaluation framework
|
|
- [Testing](TESTING.md) - Testing guide
|