Thin wrapper around OpenLLMetry/Traceloop for AI agent observability by TraceIQ
Project description
TraceIQ
Thin Python wrapper around OpenLLMetry (Traceloop) for capturing AI-agent execution traces with cost observability. Built for waste detection — every trace ships with pre-computed token counts, USD cost, user identity, and a rolled-up summary so your analytics queries never have to map-reduce raw spans.
This is a wrapper, not a fork. It depends on the upstream package and adds field normalization, cost computation, context propagation, and structured local storage on top.
Install
pip install traceiq-sdk[openai] # + OpenAI auto-instrumentation
pip install traceiq-sdk[anthropic] # + Anthropic
pip install traceiq-sdk[langchain] # + LangChain + LangGraph
pip install traceiq-sdk[llamaindex] # + LlamaIndex
pip install traceiq-sdk[all] # everything
Install traceiq-sdk — not the unrelated traceiq package on PyPI (different library). After install, import traceiq as shown below.
From source (local development):
pip install -e ".[openai,dev]"
Quickstart
import traceiq
from traceiq import workflow, task
# 1. Init once at startup
traceiq.init(
api_key="YOUR_KEY",
endpoint="https://ingest.example.com/v1/traces",
environment="production",
version="v2.1.0",
)
# 2. Set per-request context (user, session)
traceiq.set_context(user_id="u_alice", session_id="sess_001")
# 3. Mark agent boundaries
@workflow(name="research_agent")
def run_agent(query: str):
return call_llm(query)
@task(name="call_llm")
def call_llm(query: str): ...
No endpoint → traces are written as structured JSON to ./traces/ (local dev mode, nothing leaves the machine).
init() options
| Parameter | Default | Description |
|---|---|---|
api_key |
TRACEIQ_API_KEY env |
Auth header for the OTLP endpoint |
endpoint |
TRACEIQ_ENDPOINT env |
OTLP HTTP traces URL. Omit for local JSON mode |
app_name |
"traceiq-agent" |
Service name stamped on every span |
environment |
TRACEIQ_ENVIRONMENT env |
"production" / "staging" / "development" — separates prod cost from dev noise |
version |
TRACEIQ_VERSION env |
App/agent version — enables before/after cost comparisons across deploys |
capture_content |
True |
Capture raw prompt/response text on spans (see privacy note below) |
hash_inputs |
True |
SHA1 fingerprint of input messages written as traceiq.input.hash — no raw text stored, safe in production |
enforce_budgets |
False |
Poll per-tenant budget rules and block over-budget tenants (see below). Requires an endpoint |
traces_dir |
"./traces" |
Local JSON output directory (no-endpoint mode only) |
disable_batch |
False |
Flush synchronously — use in short-lived scripts and notebooks |
Per-request context
Call set_context() at the start of each request, before invoking the agent. Every span inside that request is automatically tagged.
traceiq.set_context(
tenant_id="acme-corp", # the customer/org — top-level attribution dimension
user_id="u_abc123", # the end user within that tenant
session_id="sess_xyz", # the conversation/session
# billing / attribution dimensions
product_id="prod_pro", # your product/SKU — the billing unit
product_name="Pro", # human-readable tier name
subscription_id="sub_001", # active plan instance (chargeback / usage billing)
task_type="support", # categorize work for cost-by-task analytics
agent="triage_agent", # which agent made the call
# subscriber / credential (meter spend per issued key)
subscriber_email="alice@acme-corp.com",
credential_name="key_live_dashboard",
# arbitrary tags — flattened to traceiq.meta.<key>
metadata={"region": "us-east-1", "feature_flag": "v2"},
)
run_agent(user_message)
# Clear between requests on long-running servers
traceiq.clear_context()
Attribution hierarchy: tenant_id → user_id → session_id. On a multi-tenant platform, tenant_id is "which of your customers" (the dimension you bill or enforce budgets against), while user_id is "which of their end-users." Everything is optional — set whichever dimensions your analytics and billing need.
Billing dimensions (product_id, subscription_id, credential_name, …) are what turn a cost dashboard into per-customer/per-product metering you can bill or charge back against. **task_type** buckets spend by the kind of work (support vs code_review), and **metadata** carries anything else — each key becomes a traceiq.meta.<key> span attribute (values must be scalar; non-scalars are stringified; capped at 50 keys/span).
Context is stored in a ContextVar so it propagates correctly through asyncio tasks (LangGraph) and threads (Traceloop installs ThreadingInstrumentor automatically).
set_context() merges into existing context — you can call it at auth time for tenant_id / subscriber_email, again later for session_id / task_type, and the metadata bag merges key-by-key — without losing earlier values.
Input hashing vs. content capture
hash_inputs=True (default) writes a SHA1 fingerprint of the input messages to traceiq.input.hash. No raw text is stored — this is safe in production and enables duplicate/retry detection (#2, #6 in the waste taxonomy) purely from the hash.
capture_content=True stores the full prompt and completion text on spans as gen_ai.input.messages / gen_ai.output.messages. This enables semantic analysis (unused tool output, semantic duplicates) but means PII and confidential prompt content will appear in exported telemetry. Set capture_content=False in production if prompt text must not leave the execution environment.
The two flags are independent — you can run hash_inputs=True, capture_content=False in production to get duplicate detection without any raw text leaving.
Marking agent boundaries
@workflow, @task, @tool, and @agent are re-exported directly from Traceloop — no reimplementation.
from traceiq import workflow, task, tool, agent
@workflow(name="research_agent") # root of the trace tree
def run_agent(query: str): ...
@task(name="classify_intent") # named step within the workflow
def classify(query: str): ...
@tool(name="web_search") # tool call step
def search(query: str): ...
Auto-instrumented frameworks (OpenAI, Anthropic, LangChain, LangGraph, LlamaIndex) emit LLM call spans automatically as children of these boundaries — you only need to add decorators for your own business logic steps.
Span field contract
Every span carries these traceiq.* attributes:
Identity & context
| Field | Description |
|---|---|
traceiq.environment |
Set at init() — e.g. "production" |
traceiq.version |
App version set at init() |
traceiq.app_name |
Service name set at init() |
traceiq.tenant_id |
Customer/organization — top-level attribution dimension, set via set_context() |
traceiq.user_id |
End user within the tenant — set via set_context() |
traceiq.session_id |
Conversation/session — set via set_context() |
traceiq.product_id |
Your product/SKU — the billing unit a tenant is subscribed to |
traceiq.product_name |
Human-readable product/tier name |
traceiq.subscription_id |
Active subscription/plan instance — for chargeback and usage-based billing |
traceiq.task_type |
Category of agent work (support, code_review, …) for cost-by-task analytics |
traceiq.agent |
Which agent/service made the call |
traceiq.subscriber.id |
Stable subscriber/account identifier — the durable billing key (prefer over email) |
traceiq.subscriber.email |
Subscriber contact — per-user billing views without a separate lookup |
traceiq.credential.id |
Stable credential identifier — meter spend per issued key |
traceiq.credential.name |
API credential label — human-readable key name |
traceiq.attempt / is_retry |
Retry signals from set_context — makes retry waste queryable directly |
traceiq.meta.<key> |
Arbitrary tags from set_context(metadata=…) (scalar values, ≤50 keys/span) |
traceiq.workflow.id |
Stable hex ID derived from workflow name (same ID every run of the same workflow) |
| traceiq.input.hash | SHA1 of input messages — present when hash_inputs=True (default). Use for duplicate/retry detection without storing raw content |
| traceiq.output.hash | SHA1 of output messages — enables "same prompt, different answer" / semantic-retry analysis |
Step classification
| Field | Description |
|---|---|
traceiq.step.type |
workflow / task / llm / tool / retriever / agent |
traceiq.model |
Actual model used (gen_ai.response.model → gen_ai.request.model fallback) |
traceiq.requested_model |
Model the caller asked for (gen_ai.request.model) |
traceiq.model_routing_mismatch |
True when the served model isn't an extension of the requested one — catches routing waste (asked cheap, served pricey) |
traceiq.provider |
Serving provider — openai / anthropic / … (gen_ai.provider.name → gen_ai.system) |
traceiq.operation.type |
LLM operation class — chat / text_completion / embeddings … |
traceiq.response.id |
Provider's own response/request id — reconcile spans against provider invoices |
Token usage
| Field | Description |
|---|---|
traceiq.usage.input_tokens |
Normalized — reads gen_ai.usage.input_tokens or legacy gen_ai.usage.prompt_tokens |
traceiq.usage.output_tokens |
Normalized — reads gen_ai.usage.output_tokens or legacy gen_ai.usage.completion_tokens |
traceiq.usage.total_tokens |
Provider-reported total; derived as input + output when the provider omits it |
traceiq.usage.cache_read_tokens |
Reads both OTel dot form and Traceloop underscore form |
traceiq.usage.cache_write_tokens |
Reads both OTel dot form and Traceloop underscore form |
traceiq.usage.reasoning_tokens |
OpenAI o-series internal reasoning tokens (visibility only — already included in output_tokens) |
traceiq.usage.cost_usd |
Computed total USD cost for this span (absent for unknown models — see pricing table) |
traceiq.usage.image_count / pixel_count / audio_seconds / character_count / credits |
Multimodal usage — present when the provider emits it (best-effort) |
traceiq.usage.cost_input_usd |
Portion of cost_usd attributable to input tokens |
traceiq.usage.cost_output_usd |
Portion of cost_usd attributable to output tokens |
traceiq.usage.cost_cache_usd |
Portion of cost_usd from cache read + write tokens |
traceiq.usage.cost_image_usd / cost_audio_usd / cost_credits_usd |
Modality cost components — present only when nonzero |
traceiq.tool_cost_usd |
Tool/API call cost, set via record_metric(tool_cost=…) |
traceiq.escalation_cost_usd |
Human review/takeover cost, set via record_metric(escalation_cost=…) |
traceiq.quality_score |
Evaluation score, set via record_metric(quality_score=…) |
traceiq.metric.<key> |
Arbitrary numeric units (OCR pages, retrieved rows…), set via record_metric(**custom) |
Performance
| Field | Description |
|---|---|
traceiq.streaming |
True when the call was streamed (gen_ai.is_streaming) |
traceiq.ttft_ms |
Time-to-first-token in ms. Derived from the first gen_ai.content.completion.chunk span event minus span start. Streaming calls only — absent for non-streaming |
traceiq.throughput_tps |
Output tokens per second over the span duration. Present for streaming and non-streaming whenever output tokens + duration exist |
Streaming chunk events are collapsed. OpenLLMetry emits one
gen_ai.content.completion.chunkspan event per streamed token. After reading the first one for TTFT, the exporter keeps only that first event and drops the rest, recording the total asstreamed_chunk_counton the span — so a long stream stays KB-sized instead of ballooning the trace file with thousands of near-empty events.
Status & errors
| Field | Description |
|---|---|
traceiq.status |
"ok" or "error" (OTel UNSET → "ok") |
traceiq.finish_reason |
First stop reason — stop / length / tool_calls … |
traceiq.truncated |
True when finish_reason is length/max_tokens — a token-cap waste signal |
traceiq.exception.type |
Exception class name, copied from span event to attribute for queryability |
traceiq.exception.message |
Exception message, copied from span event |
All raw upstream attributes (gen_ai.*, traceloop.*, trace_id, span_id, parent_span_id, start/end timestamps, events) are preserved untouched.
Trace file format (local mode)
One file per trace: traces/trace_{trace_id}.json
{
"metadata": {
"trace_id": "d24b6381...",
"workflow_id": "92f34491...",
"workflow_name": "research_agent",
"tenant_id": "acme-corp",
"user_id": "u_alice",
"session_id": "sess_001",
"product_id": "prod_pro",
"product_name": "Pro",
"subscription_id": "sub_001",
"task_type": "support",
"agent": "triage_agent",
"subscriber_email": "alice@acme-corp.com",
"credential_name": "key_live_dashboard",
"environment": "production",
"version": "v2.1.0",
"app_name": "my-agent",
"metadata": { "region": "us-east-1", "feature_flag": "v2" },
"start_time": "2026-06-25T12:47:53.566797+00:00",
"end_time": "2026-06-25T12:47:53.784210+00:00",
"total_duration_ms": 217.4
},
"summary": {
"status": "ok",
"span_count": 5,
"llm_call_count": 2,
"tool_call_count": 1,
"truncated_call_count": 0,
"error_span_count": 0,
"retried_call_count": 0,
"duplicate_call_count": 0,
"routing_mismatch_count": 0,
"max_tree_depth": 4,
"max_tool_repeats": 1,
"models_used": ["gpt-4o-mini-2024-07-18"],
"providers_used": ["openai"],
"total_input_tokens": 762,
"total_output_tokens": 89,
"total_cache_read_tokens": 100,
"total_cache_write_tokens": 0,
"total_cost_usd": 0.00017520,
"total_input_cost_usd": 0.00011430,
"total_output_cost_usd": 0.00005340,
"total_tool_cost_usd": 0.00500000,
"total_escalation_cost_usd": 0.0,
"total_spend_usd": 0.00517520,
"total_cache_savings_usd": 0.00002500,
"avg_quality_score": 0.91,
"avg_ttft_ms": 184.2,
"avg_throughput_tps": 47.6
},
"spans": [ ... ]
}
A few of the waste rollups have semantics worth knowing:
duplicate_call_countcounts repeats of anytraceiq.input.hashacross all span types carrying one (in production that's mostly LLM spans, since the hash needs content on the span).max_tree_depthmeasures span nesting (workflow → task → tool → …), not the number of LLM rounds. For a flat ReAct-style loop, watchllm_call_countandmax_tool_repeatsinstead — they catch the wide-but-shallow case that depth misses.retried_call_countcounts model spans only —is_retry/attemptare request-scoped (stamped on every span), so counting all spans would inflate it to the whole workflow.
The summary block is pre-aggregated at write time — waste queries read only metadata + summary without iterating spans:
import json, pathlib
traces = [json.loads(f.read_text()) for f in pathlib.Path("./traces").glob("trace_*.json")]
# Most expensive traces
by_cost = sorted(traces, key=lambda t: t["summary"]["total_cost_usd"], reverse=True)
# Errored traces that still burned tokens
errored_with_spend = [
t for t in traces
if t["summary"]["status"] == "error"
and t["summary"]["total_cost_usd"] > 0
]
# Per-tenant cost rollup (which customer is costing you the most)
from collections import defaultdict
by_tenant = defaultdict(float)
for t in traces:
by_tenant[t["metadata"]["tenant_id"]] += t["summary"]["total_cost_usd"]
# Per-user cost within a tenant
alice = [t for t in traces if t["metadata"]["user_id"] == "u_alice"]
alice_total = sum(t["summary"]["total_cost_usd"] for t in alice)
Flush behaviour
| Trigger | When |
|---|---|
| Root span detected | Immediately when the @workflow span ends — all children are already buffered |
shutdown() |
Process exit — drains all remaining buffers (BatchSpanProcessor safety net) |
| Reaper thread | Every stale_timeout/3 seconds — flushes traces idle longer than stale_timeout (default 30s) |
Cost engine
USD cost is computed on every LLM span using a built-in pricing table (verified 2026-06-25). Covered models:
Anthropic — Claude Fable 5, Mythos 5, Opus 4.x, Sonnet 4.x, Haiku 4.x, Haiku 3.5, Claude 3 Opus/Sonnet/Haiku
OpenAI — GPT-5.5, GPT-5.4, GPT-4.1/mini/nano, GPT-4o/mini, o4-mini, o3-mini, o3, o1/mini, GPT-4 Turbo, GPT-4, GPT-3.5 Turbo
All four token types are priced separately where applicable: input, output, cache_read, cache_write. Cost is split on each span into cost_input_usd / cost_output_usd / cost_cache_usd.
Unknown models emit a warning and omit cost_usd rather than writing a wrong number:
WARNING traceiq: unknown model 'my-local-llm' — cost_usd will be absent from span.
Add it to traceiq/_pricing.py to enable cost tracking.
Prices are version-prefix matched (claude-opus-4-8-20260528 → claude-opus-4 entry) so new minor releases are covered automatically without a code change.
Beyond text tokens
The engine is modality-aware. A table entry can carry, instead of (or alongside) token rates:
| Modality | Price key(s) | Cost formula | Cost bucket |
|---|---|---|---|
| Embeddings | input (no output) |
input-only token cost | input |
| Image | image_by_size{size: usd} / image_flat / image_per_pixel |
per image by size, flat per image, or per pixel | image |
| Audio STT | audio_per_second |
rate × audio_seconds |
audio |
| Audio TTS | audio_per_char |
rate × character_count |
audio |
| Credits | credit_usd |
rate × credits_consumed |
credits |
Built-in multimodal entries (DALL·E 2/3, gpt-image-1, Whisper, gpt-4o-transcribe, tts-1/-hd, text-embedding-3) ship as illustrative rates — verify before billing. Multimodal usage counts (image_count, audio_seconds, …) are read best-effort from span attributes; their semconv names aren't standardized yet, so if your instrumentation doesn't emit them, set the attribute yourself (or extend traceiq/_compat.py) to drive cost.
Tool & quality economics
The SDK can't know what a tool call costs you or how good a response was — call record_metric() from inside an agent boundary to stamp those onto the active span:
from traceiq import record_metric, tool, workflow
@tool(name="web_search")
def search(q):
hits = vendor_search(q) # this API costs you $0.005/call
record_metric(tool_cost=0.005)
return hits
@workflow(name="support_agent")
def run(msg):
answer = agent(msg)
record_metric(
quality_score=grade(answer), # → traceiq.quality_score
escalation_cost=2.50, # human takeover → traceiq.escalation_cost_usd
retrieved_docs=8, # arbitrary → traceiq.metric.retrieved_docs
)
return answer
These roll up into the trace summary as total_tool_cost_usd, total_escalation_cost_usd, avg_quality_score, and — most importantly — **total_spend_usd**, the true cost of the trace (model cost + tool cost + escalation cost). record_metric() is a safe no-op when called with no active span.
Tool cost registry
To avoid calling record_metric() inside every tool, register flat per-call costs once at startup. They're stamped automatically onto matching @tool spans (an explicit record_metric(tool_cost=…) still wins):
from traceiq import register_tool_cost
register_tool_cost("web_search", 0.005)
register_tool_cost("human_review", 2.50)
Budget enforcement
Enable with enforce_budgets=True (requires an endpoint). On init(), a daemon
thread polls your server every 30s and caches per-tenant budget rules in memory:
GET {origin}/v1/budgets/{app_name}
The endpoint returns a list of rules (or {"budgets": [...]}):
[
{ "tenant_id": "acme", "limit_usd": 100.0, "current_usd": 97.3, "action": "block" },
{ "tenant_id": "globex", "limit_usd": 50.0, "current_usd": 50.0, "action": "block" }
]
Your server owns current_usd (it sees all traffic across all your customers' processes).
The SDK only reads the rules and enforces them. A rule with "action": "block" whose
current_usd >= limit_usd causes a BudgetExceededError. Any other action (e.g. "alert")
never blocks.
Blocking before the spend
enforce_budgets wires the check into span processing, but to stop a call before
it reaches the provider, guard your agent entrypoint with check_budget():
from traceiq import check_budget, BudgetExceededError, set_context
set_context(tenant_id="acme", user_id="u_alice")
try:
check_budget() # reads tenant_id from context; raises if over budget
answer = run_agent(user_message)
except BudgetExceededError as e:
answer = f"Budget limit reached: {e}" # short-circuit before any LLM call
check_budget() is a no-op unless enforce_budgets=True, so it's safe to leave in
code that also runs in local/dev mode.
Fail-open
If the budget server is unreachable or returns malformed data, the SDK keeps the last known rules and — with no cached rules — allows the call. A metering outage never takes your customers' agents down ("water-meter" model). All enforcement state lives in memory; nothing is persisted by the SDK.
Auto-instrumented providers
Enabled by Traceloop automatically when the corresponding package is installed — no per-provider code in this SDK:
- OpenAI
- Anthropic
- LangChain / LangGraph
- LlamaIndex
- Cohere, Pinecone, Haystack, and others — see Traceloop integrations
SDK versions targeted
traceloop-sdk≥ 0.61.0opentelemetry-semantic-conventions-ai≥ 0.5.1OTel GenAI semconv spec0.5.0
The _compat module reads current semconv attribute names first and falls back to deprecated names, so the field contract holds across SDK versions (pre- and post-v0.55).
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file traceiq_sdk-0.2.2.tar.gz.
File metadata
- Download URL: traceiq_sdk-0.2.2.tar.gz
- Upload date:
- Size: 45.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e7f8491e9f4e7f7b55c43d8b2e29d0395c745aeb89e5d01d68c5872a9eb5be93
|
|
| MD5 |
f793d51a777a7554a9876a864ac2765f
|
|
| BLAKE2b-256 |
388600ae13d6afeba5165f37c1af531be5bfcbc5a34482527473e0c3283c882d
|
File details
Details for the file traceiq_sdk-0.2.2-py3-none-any.whl.
File metadata
- Download URL: traceiq_sdk-0.2.2-py3-none-any.whl
- Upload date:
- Size: 43.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5b21e718f4757b0fb40ee007246a01f589b253216b470bf2b57a34de5c1da400
|
|
| MD5 |
f93d873c72a91d43de6156f5ce8e632d
|
|
| BLAKE2b-256 |
22a1e480bd5897083118d1ac46a8f2103b0bb5e1b075cdb89e68eea8ca27b316
|