Python SDK (aobs)¶
Status: Phase 2 (2026-10-12). Version 0.1.0.dev0, not yet published to PyPI. Python ≥ 3.11.
No third-party dependencies. Emits wire schema v1.0 (event-model.md).
Install¶
From a checkout of this repository:
uv sync # installs aobs (editable) + the server model + dev tools
# or, into another environment:
pip install ./sdk/python
Quick start¶
import aobs
from aobs.integrations import openai_compat
from openai import OpenAI
aobs.init(service_name="support-agent", endpoint="http://localhost:8000") # or AOBS_ENDPOINT
client = openai_compat.wrap(OpenAI(), provider="openai")
@aobs.tool
def get_order_status(order_id: str) -> dict: ...
with aobs.run("answer_ticket", agent="support_agent", session_id=ticket_id):
reply = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS)
get_order_status("1042")
Every model call and tool call inside the with block becomes a child span of the run. A
flush happens automatically at interpreter exit; call aobs.shutdown() yourself in scripts
and short-lived jobs (serverless) so you control the wait.
To guard side-effecting tools (refunds, payments) with policy checks, approval and duplicate prevention, see action-safety.md.
To store runs in the collector, pass endpoint= and api_key= (or set AOBS_ENDPOINT and
AOBS_API_KEY); see ingestion.md. For a local collector and dashboard in one
command, see docker.md. Without a collector, use
exporter=JsonlFileExporter("traces.jsonl") (see the example agent) or your own exporter.
API¶
| Call | Creates | Notes |
|---|---|---|
aobs.run(name, *, agent=, agent_version=, agent_id=, session_id=, attributes=) |
agent span, new trace |
One run = one trace. If a span is already active, the new trace is linked to it (triggered_by). |
aobs.span(name, *, kind="step", attributes=, links=, parent=) |
child span | Generic operation; kind is normalized ("Planner Step" → planner_step). |
aobs.agent_span(name, *, version=, agent_id=) / @aobs.agent |
agent child span |
Sub-agent within the current trace. |
aobs.tool_span(name, *, tool_type=, call_id=) / @aobs.tool |
tool span |
The decorator fingerprints the bound arguments (receiver self/cls excluded, defaults applied) and records arguments/result as content per the capture policy (capture=False disables). Sync and async functions; generator functions are passed through uninstrumented (counted as uninstrumented_calls). |
aobs.llm_span(*, provider, request_model=, operation="chat") |
llm span |
For providers without an integration. |
aobs.event(name, attributes) |
span event | On the current span. |
aobs.current_span() |
— | A non-recording span when there is none. |
aobs.with_current_context(fn) |
— | Carry the current span into threads / thread pools. asyncio needs nothing. |
aobs.flush(timeout_s=5) → bool |
— | Export everything queued, including starts of still-open spans. False on timeout. |
aobs.shutdown(timeout_s=None) → bool |
— | Flush (bounded), stop the worker, shut the exporter down. |
aobs.diagnostics() → dict |
— | Counters below plus enabled, disabled_reason, queued, capture, fingerprint_key_id. |
Span methods (all safe in any state; invalid values are dropped and counted, never raised):
set_attribute(s), add_link(trace_id, span_id, relation), set_retry(attempt, max_attempts),
set_tool(name=, type=, call_id=), set_llm(provider=, operation=, request_model=,
response_model=, provider_request_id=, request={…}, response={…}, streaming={…}),
fingerprint_input(value), set_input(value, format), set_output(value, format),
set_content_unavailable(direction), record_usage(input_tokens=, output_tokens=, total_tokens=,
cache_read_input_tokens=, cache_write_input_tokens=, reasoning_tokens=, source=),
record_cost(amount, currency) (provider-reported only), set_status(status),
set_error(exc_or_type, message=, origin=, code=, retryable=), record_exception(exc),
event(name, attributes), end(status=None).
Statuses and errors¶
- Leaving a span with an
Exception→error, witherror.type(module-qualified class name),message(scrubbed),origin:toolfor tool spans,providerfor LLM spans,timeoutforTimeoutError,applicationotherwise. The exception is re-raised unchanged. asyncio.CancelledError,KeyboardInterrupt,SystemExit,GeneratorExit→cancelled.span.set_error(...)marks a failure without raising (e.g. a tool that returns an error value).- Durations come from the monotonic clock;
end_time = start_time + duration, so wall-clock adjustments during a span cannot produce negative or inconsistent durations.
Usage and cost: nothing is invented¶
record_usagerecords only counts you pass; missing or invalid counts (negative, non-integer, boolean) stay unknown — they are omitted, never zero-filled. If no valid count remains, no usage is recorded.sourcemust beprovider_reportedorsdk_estimated.record_costis for cost reported by the provider itself. The SDK never computes cost; platform estimates are server-side (Phase 6) from usage and versioned prices.
Configuration¶
Explicit arguments to aobs.init() override environment variables, which override defaults.
| Argument | Environment | Default | |
|---|---|---|---|
service_name |
AOBS_SERVICE_NAME |
script name | ≤ 128 chars |
environment |
AOBS_ENVIRONMENT |
development |
normalized to [a-z0-9_.-] |
service_version |
AOBS_SERVICE_VERSION |
— | e.g. git SHA |
endpoint |
AOBS_ENDPOINT |
— | collector base URL; /v1/ingest is appended |
api_key |
AOBS_API_KEY |
— | sent only as Authorization: Bearer; never logged |
exporter |
— | HTTP if endpoint set, else telemetry disabled (with a warning) |
any Exporter |
enabled |
AOBS_ENABLED |
true |
false turns every call into a no-op |
capture |
AOBS_CAPTURE |
metadata_only |
metadata_only | redacted | full |
capture_error_messages |
AOBS_CAPTURE_ERROR_MESSAGES |
true |
scrubbed |
capture_stack_traces |
AOBS_CAPTURE_STACK_TRACES |
false |
scrubbed, last 32 KiB |
max_content_bytes |
— | 32768 | per content part (256–65536) |
redactors, use_default_redactors |
— | defaults on | extra Callable[[str], tuple[str, int]] |
fingerprint_inputs |
— | true |
|
fingerprint_key, fingerprint_key_id |
AOBS_FINGERPRINT_KEY (hex), AOBS_FINGERPRINT_KEY_ID |
random per process | key ≥ 32 bytes |
strict |
— | false |
raise ConfigError instead of warning and falling back |
max_queue_size |
— | 10000 | events held in memory (100–1,000,000) |
max_batch_events |
— | 500 | ≤ 1000 (schema limit) |
max_batch_bytes |
— | 900000 | per request (16 KiB – 1 MiB) |
schedule_delay_s |
— | 1.0 | export interval |
start_delay_s |
— | 1.0 | start coalescing window |
export_timeout_s |
— | 10 | per HTTP request |
max_export_attempts |
— | 4 | then the batch is dropped and counted |
retry_backoff_initial_s, retry_backoff_max_s |
— | 0.5, 30 | jittered exponential backoff |
shutdown_timeout_s |
— | 5 | used at exit and by shutdown() |
Out-of-range tuning values are clamped (and rejected with strict=True). Unknown argument names
raise TypeError like any Python function.
Privacy¶
| Mode | Prompt / response / tool arguments / results | Content status sent |
|---|---|---|
metadata_only (default) |
not sent | not_captured |
redacted |
sent after redaction (secrets, e-mail addresses, card numbers that pass a Luhn check, plus your redactors) | redacted (+ redaction_count) |
full |
sent as is, size-capped (truncated: true when cut) |
captured |
Always, in every mode: error messages, stack traces and string attributes are secret-scrubbed
(bearer tokens, sk-…/pk-… keys, AWS access keys, GitHub/Slack tokens, JWTs, private-key
blocks, URL credentials, password=…-style assignments), and error messages and stack traces
also lose e-mail addresses and card numbers that pass a Luhn check ([REDACTED:email],
[REDACTED:card_number]), whatever use_default_redactors says. Other personal data (names,
addresses, ids) is not recognized: set capture_error_messages=False if your exceptions can carry
it. A private key whose -----END …----- marker is missing is removed up to the end of the text.
To keep redaction fast, a message longer than 16 KiB is cut to its first 16 KiB, a string
attribute longer than 8 KiB to its first 8 KiB, and a stack longer than 64 KiB to its last 64 KiB
before scrubbing, and the text around the cut (the partial token, plus up to 64 characters, any
unclosed private-key block and, at a stack's cut, the value after a cut-off Bearer/password=)
is dropped rather than sent, so a secret split by the cut never leaves half-redacted. A single
whitespace-free token longer than that is therefore dropped whole. The collector applies the
same rules again before storing. Full-mode content is not scrubbed —
use redacted if content may contain credentials. The SDK cannot stop code from putting personal
data into span names or attributes; don't.
Structured content larger than max_content_bytes is sent as truncated JSON text
(format: "text", truncated: true) rather than a broken structure. An event too large for one
batch has its content and attributes removed (content reported as not_captured) and is counted
as events_degraded_oversize.
Input fingerprints¶
input_fingerprint = hmac-sha256:<key_id>:<hex> over canonical JSON of the input (tool
arguments; LLM request arguments excluding transport options such as timeout, stream,
extra_headers). It lets the platform spot repeated identical calls without seeing the inputs.
- The key never leaves the process: not in events, not in
repr, not in diagnostics; theFingerprinterrefuses to be pickled. - Default key: random per process — fingerprints compare within one process (and its forked
children). For comparison across processes and runs, configure a stable key (≥ 32 bytes, e.g.
python -c "import secrets; print(secrets.token_hex(32))") and a key id; rotate both together. - Equal inputs give equal fingerprints. Sets are sorted first, so their order (which depends on
the process's hash seed) does not matter. An input containing a value whose only text form is
its memory address (an object without a
__repr__/__str__, a function, a lock, …) has no stable form: the span gets no fingerprint, andfingerprints_unavailableis counted. Give such argument types a__repr__that shows their state, or exclude them (e.g. pass ids, not client objects). A__repr__that omits state (Order()) makes different inputs look equal.
Delivery guarantees¶
- Never blocks, never raises. Recording a span takes tens of microseconds (see
benchmarks.md) and does no I/O. JSON encoding and network calls happen on one
background thread (
aobs-exporter). - Bounded memory. At most
max_queue_sizeevents (about 1.7 KB each) are held; beyond that new events are dropped and counted. - Start coalescing. A span's
span_startis only exported if the span is still running afterstart_delay_s(or at a flush). Short spans cost one event; long or hung spans stay visible. - Bounded retries. Network errors, timeouts, 408/429/5xx are retried up to
max_export_attemptswith jittered backoff (Retry-Afterhonoured up to the maximum backoff); other 4xx are not retried. Retries are safe because ingest is idempotent. - Collector unavailable: the application is unaffected; each batch is attempted
max_export_attemptstimes and then dropped (events_dropped_export_failed); the queue fills and further events are dropped (events_dropped_queue_full);flush()returns False after its timeout. Nothing is persisted to disk in this version. - Redirects are not followed (they could forward the
Authorizationheader). - Fork-safe (gunicorn, celery, multiprocessing with fork): the child starts with an empty queue and its own worker thread.
- Abandoned streams (an LLM stream neither exhausted nor closed) are recorded as
cancelledfrom the finalizer without taking locks there; the span is ended by the worker thread.
Diagnostics counters¶
spans_started, spans_ended, span_double_end, events_enqueued, starts_coalesced,
events_exported, batches_exported, export_attempts, export_retries, batches_failed,
events_dropped_queue_full, events_dropped_after_shutdown, events_dropped_export_failed,
events_dropped_oversize, events_degraded_oversize, events_rejected_by_collector,
attributes_dropped, values_sanitized, invalid_usage_values, invalid_cost_values,
content_truncated, uninstrumented_calls, fingerprints_unavailable, internal_errors,
config_warnings, plus
last_error / last_error_at. Problems are also logged on the aobs logger at WARNING, at most
once a minute per category.
Exporters¶
HttpExporter(endpoint, api_key=None, headers=None), JsonlFileExporter(path) (one batch per
line, file mode 0600), InMemoryExporter() (tests), OtlpHttpExporter(endpoint, headers=None)
(OTLP/HTTP JSON to an OpenTelemetry Collector, Jaeger, Tempo, ...; see
opentelemetry.md) and FanOutExporter(*exporters) (send every batch to
several exporters, retrying each destination separately). To add a transport, implement:
class MyExporter:
def export(self, batch: aobs.export.EncodedBatch, timeout_s: float) -> aobs.export.ExportResult: ...
def shutdown(self) -> None: ...
batch.body is the exact wire-format envelope; return ExportResult("success"),
ExportResult("retry", retry_after_s=…) or ExportResult("failure", message=…). Exceptions
raised by export are treated as retryable.
Integrations¶
aobs.integrations.openai_compat¶
wrap(client, provider=None) instruments client.chat.completions.create on OpenAI-compatible
clients (sync and async, streaming and non-streaming). Tested with openai 3.29.0 through its
own HTTP mock transport. Other versions are likely to work (the integration is duck-typed and
imports nothing from openai) but are not tested.
Capabilities declared in the batch resource: llm.usage, llm.streaming,
llm.request_params, llm.response_metadata, llm.content, llm.errors.
| Recorded | From |
|---|---|
request_model, temperature, top_p, max_tokens (or max_completion_tokens), stream |
request arguments |
response_model, response id, finish reasons |
response |
provider_request_id |
x-request-id (response _request_id, stream response headers, error request_id) |
usage (provider_reported) |
prompt_tokens→input_tokens, completion_tokens→output_tokens, total_tokens, prompt_tokens_details.cached_tokens→cache_read_input_tokens, prompt_tokens_details.cache_write_tokens→cache_write_input_tokens, completion_tokens_details.reasoning_tokens→reasoning_tokens |
| streaming | time to first chunk, chunk count, completed; usage only if the request set stream_options={"include_usage": True} |
| errors | type, HTTP status as code, retryable for 408/409/429/5xx and connection/timeout errors |
| content (policy permitting) | messages → OpenTelemetry GenAI {role, parts}; image/audio/file parts recorded by type only |
Not instrumented: with_raw_response, with_streaming_response, other endpoints (Responses API,
embeddings). Not recorded: cost (not reported by Chat Completions), headers, API keys.
aobs.integrations.langchain¶
CallbackHandler() is a LangChain callback handler: pass it as config={"callbacks": [handler]}
to graph.invoke(...) / ainvoke / stream and LangGraph nodes, chains, chat model calls,
tools and retrievers become nested agent / step / llm / tool / retrieval spans, with
usage only when LangChain reports it. langchain-core is imported lazily. Tested with
langchain-core 1.6.13 and langgraph 1.2.16 using LangChain's fake chat models. See
integrations/langchain.md and the offline example
examples/langgraph_agent.
aobs.integrations.anthropic¶
wrap(client, provider=None) instruments client.messages.create (streaming and not),
client.messages.stream(...) and client.messages.parse(...) on Anthropic / AsyncAnthropic
clients. Tested with anthropic 2.1.0 through a mocked HTTP transport, not against the real
API. Usage is mapped so that input_tokens includes cache reads and writes (Anthropic reports
them separately); cache_creation_input_tokens → cache_write_input_tokens,
output_tokens_details.thinking_tokens → reasoning_tokens; total_tokens stays unknown.
tool_use / tool_result blocks become tool_call / tool_call_response content parts. Details,
including streaming and error mapping: integrations/anthropic.md.
Live smoke tests¶
sdk/python/tests/live/ holds one tiny real call per integration (openai_compat, anthropic)
that checks the recorded span carries provider-reported usage and the requested model. They are
skipped unless you opt in, so the default pytest run and CI never contact a provider; the
skip reason is printed in the summary. They have not been run by CI, and whoever runs them
pays for the calls.
| Variable | Needed for |
|---|---|
AOBS_LIVE_TESTS=1 |
both (the opt-in) |
OPENAI_API_KEY, AOBS_LIVE_OPENAI_MODEL |
test_live_openai_compat |
ANTHROPIC_API_KEY, AOBS_LIVE_ANTHROPIC_MODEL |
test_live_anthropic |
There is no default model: a test whose model variable is unset is skipped. Pick a cheap model your account can use. Each call asks for at most 64 output tokens.
AOBS_LIVE_TESTS=1 ANTHROPIC_API_KEY=... AOBS_LIVE_ANTHROPIC_MODEL=<model> \
OPENAI_API_KEY=... AOBS_LIVE_OPENAI_MODEL=<model> \
uv run pytest sdk/python/tests/live -v -rs
The clients read their usual environment (OPENAI_BASE_URL, ANTHROPIC_BASE_URL, …), so unset
those unless you mean to test against them.
Known limitations (Phase 2)¶
- Python only; no cross-process trace propagation helpers (W3C
traceparent) yet. - Generator / async-generator functions decorated with
@aobs.tool/@aobs.agentare not instrumented (counted asuninstrumented_calls). - No on-disk buffering: telemetry produced while the collector is down beyond the retry budget is dropped (and counted).
- No sampling or adaptive capture yet (Phase 9).
- Overhead per instrumented tool call (~57 µs median on the reference VM) is above the 50 µs roadmap target; see benchmarks.md for the measured profile and candidates.