LLM observability SDK with OTEL tracing, auto-instrumentation, and REST management API.
1.5K
This guide covers the technical architecture and end-user usage for the Python-based observability components.
This diagram illustrates the lifecycle of a span from application capture to background enrichment. The SDK now includes a REST Management API for remote control and discovery.
┌────────────────┐ ┌──────────────────┐ ┌───────────────────┐
│ User App │ capture │ instrumentation │ queue │ Cloudflare Queue │
│ (Python/JS) ├─────────>│ -sdk ├─────────>│ (span-enrichment) │
└────────────────┘ └─────────┬────────┘ └─────────┬─────────┘
│ │
│ REST API (8000) │ trigger
v v
┌────────────────┐ ┌──────────────────┐ ┌───────────────────┐
│ Analytics DB │ storage │ Remote Control │ response │ queue-embedding │
│ (ClickHouse) │<─────────┤ (Init/Detect) │<─────────┤ -worker │
└────────────────┘ └──────────────────┘ └─────────┬─────────┘
│
│ HTTP call
v
┌───────────────────┐
│ Cloudflare AI │
│ (Workers AI API) │
└───────────────────┘
The instrumentation-sdk is designed to be developer-friendly, requiring minimal code changes to start capturing observability data.
pip install instrumentation-sdk
The fastest way to get observability is to use auto-instrumentation. This patches the underlying HTTP calls of popular LLM clients transparently.
from instrumentation_sdk import init_auto_instrumentation
# Initialize at the start of your application
init_auto_instrumentation()
# Now any call to OpenAI, Anthropic, LiteLLM, or LangChain is tracked automatically
import openai
client = openai.AsyncOpenAI()
response = await client.chat.completions.create(model="gpt-4o", messages=[...])
Supported Providers:
openai.AsyncOpenAIanthropic.AsyncAnthropiclitellm.acompletionBaseChatModel (via ainvoke)The SDK provides a built-in FastAPI-based management layer for remote orchestration.
| Endpoint | Method | Description |
|---|---|---|
/instrumentation/init | POST | Remotely initialize auto-instrumentation. |
/instrumentation/uninstrument | POST | Disable all active instrumentation. |
/instrumentation/detect | POST | Discovery: Detect provider/model from a sample request body. |
/instrumentation/test-call | POST | Verification: Trigger a sample LLM call to verify end-to-end tracing. |
/streaming/test-stream-call | POST | Verification: Trigger a mock streaming call to verify streaming/TTFT. |
/v1/sampling/should-sample | POST | Verification: Check if a span should be sampled. |
/v1/embeddings/embed | POST | Verification: Generate MiniLM embeddings for a given text. |
Use the @llm_observe decorator to manually track functions.
from instrumentation_sdk import llm_observe
# (1) Decorate your LLM-calling functions
@llm_observe(service="payment-bot", endpoint="gpt-4o")
def get_llm_response(prompt: str):
# Your existing LLM logic here
# status, latency, and span_ids are captured automatically
return response
# (2) Support for Async functions
@llm_observe(service="search-agent", endpoint="claude-3")
async def get_async_response(prompt: str):
return await client.completions.create(...)
For callers who need to set metadata mid-call (e.g., after routing to a specific model or determining usage), use the llm_span context manager. It supports both synchronous and asynchronous usage.
from instrumentation_sdk import llm_span
async def my_handler(req):
# (1) Start a span with initial metadata
async with llm_span(model="gpt-4o", user_id=req.user_id) as span:
# (2) Perform your LLM call
response = await client.chat.completions.create(...)
# (3) Update metadata mid-call
span.set_metadata("actual_model", response.model)
span.set_metadata("prompt_tokens", response.usage.prompt_tokens)
# Span is automatically reported on exit (even if an error occurs)
If you prefer direct control over the span data, you can use the reporter manually.
from instrumentation_sdk import get_reporter
reporter = get_reporter()
reporter.report({
"span_id": "unique-id",
"service_name": "my-service",
"status": "success",
"text": "The prompt content to be enriched"
})
The instrumentation SDK API is available as a production-ready, fully self-contained All-in-One Standalone Observability Container. This container bundles the FastAPI application, Grafana, and Tempo into a single image, eliminating the need to set up external databases or visualizers manually.
Image Name: chiefj/instrumentation-sdk-api:unstable (or chiefj/instrumentation-sdk-api:latest)
To pull and run the fully integrated all-in-one container locally:
# Pull the latest standalone image
docker pull chiefj/instrumentation-sdk-api:unstable
# Run the unified all-in-one telemetry stack
docker run -d \
-p 8002:8000 \
-p 3002:3000 \
--name instrumentation-api-allinone \
chiefj/instrumentation-sdk-api:unstable
http://localhost:8002http://localhost:3002 (Tempo is automatically provisioned as a read-only datasource and ready to query!)llm-observe)To simplify launching and managing the all-in-one container, the SDK provides a built-in command-line utility named llm-observe.
Once instrumentation-sdk is installed, you can manage the observability stack with simple commands:
# Start the stack on host ports: API (8002), Grafana (3002), OTLP (4317), Prometheus (9090)
llm-observe start
# Check the status of the container stack
llm-observe status
# Stop and clean up the container stack
llm-observe stop
You can customize the container name, ports, image, and tag:
llm-observe start --name my-observability --api-port 8005 --grafana-port 3005
For development with hot-reloading, use the provided Docker Compose:
docker compose -f packages/python/instrumentation-sdk/deploy/docker/docker-compose.dev.yaml up instrumentation-api
The SDK provides automatic pre-call token counting utilizing tiktoken with fallback character-based heuristics for non-OpenAI models. It supports plain text strings, complex chat message list schemas, and OpenAI's tile-based vision token calculation.
Use count_tokens to calculate tokens directly:
from instrumentation_sdk import count_tokens
tokens, method = count_tokens("hello world", "gpt-4")
Use llm_span_with_tokens to automatically record prompt_tokens and token_count_method inside manual spans:
from instrumentation_sdk import llm_span_with_tokens
async def handle_request(req):
async with llm_span_with_tokens(model="gpt-4", provider="openai", prompt="hello world") as span:
pass
The /v1/token-counting/count REST API endpoint supports counting prompt tokens:
curl -X POST http://localhost:8000/v1/token-counting/count \
-H "Content-Type: application/json" \
-d '{"prompt": "hello world", "model": "gpt-4"}'
The SDK provides specialized utilities for tracking streaming LLM calls. It wraps generators/iterators to:
Use llm_streaming_span, wrap_stream (for synchronous generators), and wrap_async_stream (for asynchronous generators):
from instrumentation_sdk import llm_streaming_span, wrap_stream, wrap_async_stream
# 1. Synchronous Streaming
with llm_streaming_span(model="gpt-4", provider="openai", prompt="Say hello") as span_ctx:
raw_generator = ["Hello", " world", "!"]
wrapped_stream = wrap_stream(raw_generator, span_context=span_ctx, model="gpt-4")
for chunk in wrapped_stream:
print(chunk)
# 2. Asynchronous Streaming
async with llm_streaming_span(model="gpt-4", provider="openai", prompt="Say hello") as span_ctx:
async def async_generator():
yield "Hello"
yield " world"
wrapped_stream = wrap_async_stream(async_generator(), span_context=span_ctx, model="gpt-4")
async for chunk in wrapped_stream:
print(chunk)
span_ctx.set_metadata("custom_field", "value") mid-stream.wrapped_stream.close() or .aclose()), the SDK captures and reports all completion tokens generated up to that point.The /v1/streaming/test-stream-call endpoint streams SSE events back to the client while validating end-to-end streaming tracing:
curl -X POST http://localhost:8000/v1/streaming/test-stream-call \
-H "Content-Type: application/json" \
-d '{"provider": "openai", "chunks": ["A", "B", "C"]}'
The SDK features an inline Aho-Corasick trie-based scanner that runs on all prompts inside manual span contexts (LLMSpanContext and LLMSpanWithTokensContext). It intercepts prompts, detects PII and SQL/prompt injection, and updates telemetry accordingly.
None or empty). The custom span attribute llm.pii_detected is set to True.llm.injection_attempt is set to True.You can import and call scan_prompt directly to inspect a prompt:
from instrumentation_sdk import scan_prompt
# Returns (pii_detected: bool, injection_attempt: bool)
pii, inj = scan_prompt("my email is [email protected]")
print(f"PII: {pii}, Injection: {inj}")
The /v1/pii-injection/scan REST API endpoint supports checking prompt contents:
curl -X POST http://localhost:8000/v1/pii-injection/scan \
-H "Content-Type: application/json" \
-d '{"prompt": "my email is [email protected]"}'
The SDK implements deterministic sampling decided at span creation time. It hashes the span_id using SHA256 and evaluates whether the hash value modulo 100 is equal to 0.
is_sampled is True): The span is processed normally, performing prompt hashing and embedding generation.is_sampled is False): The span drops/skips both the SHA256 hashing and the MiniLM embedding generation, saving computational resources.You can query the sampling logic directly:
from instrumentation_sdk import should_sample
sampled = should_sample("test-span-id")
Query the /v1/sampling/should-sample endpoint to check sampling:
curl -X POST http://localhost:8000/v1/sampling/should-sample \
-H "Content-Type: application/json" \
-d '{"span_id": "test-span-id"}'
The SDK asynchronously calls the embedding-worker HTTP endpoint (POST /embed) to generate a 384-dimensional vector embedding of the prompt text.
asyncio.create_task() to fire the embedding generation concurrently with span finalization.is_sampled is True) and no PII is detected in the prompt (pii_detected is False).None for the embedding field while the rest of the span details are still successfully emitted.from instrumentation_sdk import get_embedding
embedding = await get_embedding("your text here")
curl -X POST http://localhost:8000/v1/embeddings/embed \
-H "Content-Type: application/json" \
-d '{"text": "your text here"}'
The SDK integrates a Prometheus metrics collection pipeline to track operational metrics for LLM calls (latency, TTFT, token usage, cost, and security violations).
Initialize the Prometheus metrics scraping endpoint:
curl -X POST http://localhost:8000/v1/metrics/init \
-H "Content-Type: application/json" \
-d '{"port": 9464}'
POST /v1/metrics/initGET /v1/metrics/healthPOST /v1/metrics/recordPOST /v1/metrics/record-batchThe dashboard is built-in and automatically provisioned on port 3000 (or 3002 in standalone mode). It includes:
The SDK reads config files once at startup. After any change, a container restart is required (except dashboard JSON files which are hot-reloaded).
Edit config/model_prices.yaml:
- model: gpt-5
provider: openai
input_price_per_1m: 10.00
output_price_per_1m: 30.00
version: "2026-01-01"
Required fields: model, provider, input_price_per_1m, output_price_per_1m, version.
Prices must be >= 0. Duplicate (model, provider) pairs are rejected by CI.
Then restart:
docker restart instrumentation-sdk-api
Edit config/patterns.yaml:
patterns:
- name: phone_number
regex: "\\b\\d{3}[-.]?\\d{3}[-.]?\\d{4}\\b"
type: PII_STRUCTURAL # or INJECTION_ATTEMPT
Valid type values: PII_STRUCTURAL, INJECTION_ATTEMPT.
The CI validates that every regex compiles and no pattern name is duplicated.
Then restart:
docker restart instrumentation-sdk-api
Edit any file under build/dashboards/*.json.
No restart required — Grafana polls and hot-reloads dashboards every 30 seconds automatically.
Edit:
build/grafana-datasource.yaml — add/change datasourcesbuild/grafana-dashboard-provider.yaml — change dashboard provider path/folderbuild/prometheus.yml — add scrape targetsbuild/tempo-config.yaml — change Tempo storage or OTLP portAfter editing, rebuild the image and restart:
DOCKER_PAT=<your-pat> ./scripts/deploy_docker.sh
docker stop instrumentation-sdk-api && docker rm instrumentation-sdk-api
docker pull chiefj/instrumentation-sdk-api:latest
docker run -d -p 8000:8000 -p 3000:3000 -p 4317:4317 -p 9464:9464 \
--name instrumentation-sdk-api chiefj/instrumentation-sdk-api:latest
The grafana-config-validate.yml workflow triggers only when one of the 10 watched files changes. It validates:
| File | What is checked |
|---|---|
grafana-datasource.yaml | YAML valid, name/type/url present, Prometheus datasource exists |
grafana-dashboard-provider.yaml | YAML valid, options.path present |
prometheus.yml | YAML valid, scrape_configs non-empty |
tempo-config.yaml | server.http_listen_port, distributor.receivers.otlp, storage.trace.backend |
dashboards/*.json | JSON valid, title/panels/schemaVersion present, no duplicate UIDs |
model_prices.yaml | List non-empty, all required fields, prices >= 0, no duplicate pairs |
patterns.yaml | All required fields, valid type, no duplicate names, regex compiles |
cd packages/python/instrumentation-sdk
.venv/bin/python -m pytest tests/performance/ -m performance -v
This sends 1000 spans (100 individual + 10×50 batch) covering all 6 model/provider combos, error ratios, PII flags, and high token counts.
| Pipeline Stage | Method Call | Primary File |
|---|---|---|
| REST API | create_app() | api/rest/v1/app.py |
| Management | init_instrumentation() | api/rest/v1/handlers/instrumentation.py |
| Tracing | instrument_app() | infra/tracing/middleware.py |
| Auto-Capture | init_auto_instrumentation() | features/auto_instrumentation/index.py |
| Decorator | @llm_observe | features/spans/decorator.py |
| Context Manager | llm_span() | features/manual_instrumentation/service.py |
| Orchestration | handle_job() | worker/index.py |
| Logic | enrich_span() | features/enrich_span/service.py |
| Integration | create_embedding() | infra/clients/cloudflare_embeddings.py |
| Identity | stable_embedding_key() | shared/utils/hash.py |
| Token Counting | count_tokens() | features/token_counting/service.py |
| Streaming SDK | wrap_async_stream() | features/streaming/index.py |
| Streaming Logic | finalize_stream() | features/streaming/service.py |
| PII & Injection Scan | scan_prompt() | features/pii_injection_scan/index.py |
| Deterministic Sampling | should_sample() | features/deterministic_sampling/index.py |
| MiniLM Embedding | get_embedding() | features/minilm_embedding/index.py |
Content type
Image
Digest
sha256:3d3c854c9…
Size
402.9 MB
Last updated
3 months ago
docker pull chiefj/instrumentation-sdk-api