Python SDK
Add spans, logs, metrics, and trace context to Python applications.
telemetry-dev records spans, logs, and metrics from a Python application and sends them to telemetry.dev over OTLP/HTTP. It runs its own OpenTelemetry tracer provider and registers it globally only if you ask it to.
If your application already sets up OpenTelemetry, add the telemetry.dev span processor to your tracer provider instead. See OpenTelemetry.
Requirements
The SDK needs Python 3.10 or later. It installs OpenTelemetry Python 1.39 or later as a dependency.
The SDK tracks the active span with contextvars. asyncio tasks inherit the context, so their spans nest automatically. A new thread starts with an empty context; to keep nesting there, run the work in a copy of the caller’s context, for example with contextvars.copy_context().run().
Install
pip install telemetry-devuv add telemetry-devCreate a project at telemetry.dev and copy an API key from the project setup page. Keys look like td_live_....
TELEMETRY_DEV_API_KEY=td_live_...
OTEL_SERVICE_NAME=my-service
TELEMETRY_DEV_ENVIRONMENT=production
The SDK reads these variables from os.environ. It does not load .env files, so export the variables or load the file the way your app already does, for example with python-dotenv.
Send your first trace
Call telemetry_dev.init() once when the application starts. This example traces a small agent that calls a tool and a model:
import telemetry_dev
from telemetry_dev import log, observe, propagate_attributes, start_span
telemetry_dev.init(on_error=lambda error: print("[telemetry.dev]", error))
def call_model(question: str) -> dict:
# Stand-in for a real model call, so the example runs without provider credentials.
return {
"message": {"role": "assistant", "content": f"Echo: {question}"},
"usage": {"input_tokens": 12, "output_tokens": 8},
}
@observe(type="tool")
def search_web(query: str) -> dict:
return {"hits": [f"result for {query}"]}
def answer(question: str) -> str:
with start_span("answer", type="agent", agent_name="demo-agent"):
search_web(question)
with start_span(
"chat-completion",
type="generation",
model="example-model",
provider="example",
input=[{"role": "user", "content": question}],
) as generation:
response = call_model(question)
generation.update(
output=response["message"],
usage=response["usage"],
finish_reason="stop",
)
log("generation finished", attributes={"prompt_chars": len(question)})
return response["message"]["content"]
with propagate_attributes(user_id="user_123", session_id="session_456"):
print(answer("What is OTLP?"))
telemetry_dev.shutdown() # Sends pending data, then stops the SDK.
The run produces one trace. The answer agent span is the root. The search_web tool span and the chat-completion generation span are its children, and the log record is attached to the same trace. Every span and the log carry user.id and the session ID.
init() returns a Client with an enabled attribute and flush() and shutdown() methods. The module-level functions, such as start_span() and flush(), always use the most recently initialized client. get_client() returns that client, or None before init().
Configure init()
init() takes keyword arguments only. An argument that you pass takes precedence over its environment variable. An empty environment variable counts as unset.
| Argument | Type | Environment fallback | Default | Purpose |
|---|---|---|---|---|
api_key |
str | None |
TELEMETRY_DEV_API_KEY |
None |
Authenticates exports. Without a key, the SDK does nothing. |
base_url |
str | None |
TELEMETRY_DEV_BASE_URL |
https://ingest.telemetry.dev |
Sets the ingest URL. Trailing slashes are removed. |
environment |
str | None |
TELEMETRY_DEV_ENVIRONMENT |
production |
Sets deployment.environment.name. |
service_name |
str | None |
OTEL_SERVICE_NAME |
unknown_service |
Sets service.name. |
enabled |
bool |
None | True |
Set False to turn the SDK off. |
session_mode |
"explicit" | "process" |
None | "explicit" |
"process" gives spans and logs that have no session ID one generated ID. See Process sessions. |
register_global |
bool |
None | False |
Registers the SDK’s tracer provider as the global provider. |
export_mode |
"batched" | "immediate" |
None | "batched" |
Exports spans and logs in batches, or one at a time. |
sampler |
OpenTelemetry Sampler | None |
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG |
Parent-based, always on | Sets the trace sampling policy. |
log_level |
"debug" | "info" | "warn" | "error" | "silent" |
None | "warn" |
Sets the level of the telemetry_dev logger, which reports the SDK’s own diagnostics. |
capture_input |
bool |
None | True |
Records span input by default. |
capture_output |
bool |
None | True |
Records span output by default. |
mask |
Callable[[Any, MaskContext], Any] | None |
None | None |
Changes captured content before it is recorded. |
max_attribute_length |
int |
None | 65536 |
Caps each captured content attribute, in UTF-16 code units. |
span_filter |
Callable[[ReadableSpan], bool] | None |
None | None |
Chooses which ended spans to export. |
on_error |
Callable[[BaseException], None] | None |
None | None |
Receives the SDK’s internal errors. |
disable_atexit |
bool |
None | False |
Skips the automatic shutdown at interpreter exit. |
timeout |
float |
None | 10.0 |
Sets the timeout of each OTLP HTTP request, in seconds. |
span_exporter |
SpanExporter | None |
None | None |
Replaces the span exporter, for tests. |
log_exporter |
LogRecordExporter | None |
None | None |
Replaces the log exporter, for tests. |
metric_reader |
MetricReader | None |
None | None |
Replaces the metric reader, for tests. |
Any of the three test overrides turns the client on without an API key. Without a key, the SDK never creates network exporters.
The batch span processor uses fixed limits: a queue of 2,048 spans, a 1,000 ms delay, batches of 64 spans, and a 30,000 ms export timeout. Unlike the TypeScript SDK, init() has no batch argument.
An invalid session_mode or max_attribute_length does not raise. init() reports the error to on_error and returns a disabled client.
Trace a function with @observe
Use @observe with or without arguments:
from telemetry_dev import observe
@observe
def load_account(account_id: str):
return repository.get(account_id)
@observe(
name="run-tool",
type="tool",
capture_input=False,
capture_output=True,
attributes={"component": "account-store"},
)
def run_tool(payload):
return tool.execute(payload)
For each call, the decorator does the following:
- Names the span from
name, or the function’s__qualname__, such asAccountStore.load. - Records the arguments as input: a dictionary of parameter names to values, without
selfandcls. - Makes the span active, so spans started inside the function become its children.
- Records the return value as output.
- Records a raised exception on the span, then re-raises it.
The decorator accepts only name, type, capture_input, capture_output, and attributes. It works with regular functions, async functions, and generator functions. Before init(), or when the SDK is off, it calls the function without creating a span.
Create spans manually
start_span() returns a SpanHandle. Use it as a context manager to make the span active. Spans started inside the with block become its children. The span ends when the block exits. If the block raises, the span records the exception and the exception propagates:
from telemetry_dev import start_span
with start_span(
"model-call",
type="generation",
model="gpt-4o",
provider="openai",
input=messages,
) as span:
response = call_model(messages)
span.update(
output=response.message,
usage={"input_tokens": 12, "output_tokens": 8},
finish_reason="stop",
)
Without with, start_span() returns a detached handle. It does not make the span active, and you must end it yourself:
span = start_span("background-work")
try:
do_work()
span.end()
except Exception as error:
span.end(error=error)
raise
update_current_span(**fields) sets fields on the active span. It does nothing when no recording span is active.
Span handle
update(**fields)sets fields and returns the same handle.end(**fields, end_time=None)sets final fields and ends the span.record_output_chunk()records the arrival of a streamed output chunk. See Measure streaming latency.traceparent()returns the span’s W3Ctraceparentvalue, orNone. In the TypeScript SDK,traceparentis a property, not a method.spangives the raw OpenTelemetry span.
Before init(), or when the SDK is off, start_span() returns a handle that does nothing. Its traceparent() returns None.
Span types
The type argument sets gen_ai.operation.name and decides which attributes hold input and output. The default type is "span".
type |
gen_ai.operation.name |
Input attribute | Output attribute |
|---|---|---|---|
"span" |
function |
gen_ai.input.messages |
gen_ai.output.messages |
"generation" |
chat |
gen_ai.input.messages |
gen_ai.output.messages |
"tool" |
execute_tool |
gen_ai.tool.call.arguments |
gen_ai.tool.call.result |
"agent" |
invoke_agent |
gen_ai.input.messages |
gen_ai.output.messages |
"embedding" |
embeddings |
gen_ai.input.messages |
gen_ai.output.messages |
A tool span sets gen_ai.tool.name to the span name, and an agent span sets gen_ai.agent.name to the span name, unless you pass tool_name or agent_name. The TypeScript SDK does not set these defaults.
Span fields
start_span(), update(), end(), and update_current_span() accept these fields:
| Group | Fields |
|---|---|
| Content | input, output, system_instructions |
| Model | model, provider, response_model, response_id, output_type, finish_reason |
| Usage and cost | usage, cost_usd |
| Model request | temperature, top_p, top_k, max_tokens, stop_sequences, seed, frequency_penalty, presence_penalty |
| Stream latency | time_to_first_chunk_ms |
| Tool | tool_name, tool_call_id, tool_description |
| Agent | agent_name, agent_id |
| Other | metadata, attributes |
update(), end(), and update_current_span() also accept name, which renames the span, and error. start_span() takes the name as its first argument and does not accept error.
The fields map to the same attributes as in the TypeScript SDK. See Span fields for the full mapping. A few behaviors to know:
finish_reasonbecomes a one-itemgen_ai.response.finish_reasonsarray.time_to_first_chunk_msis converted to seconds ingen_ai.response.time_to_first_chunk.cost_usdoverrides the server’s cost calculation. Without it, the server estimates cost from token usage when it has pricing for the model. See Cost.metadatabecomestd.metadata.<key>on this span. The SDK drops the keysuserId,sessionId,user_id, andsession_id; set the user and session withpropagate_attributes()instead.attributesare applied last and can replace mapped attributes.errorsets the span status toERROR, setserror.typeto the exception’s class name, and records anexceptionevent with the message and stack trace.
start_span() also accepts these arguments:
| Argument | Type | Default |
|---|---|---|
type |
SpanType |
"span" |
parent |
str, OpenTelemetry Context, or SpanContext |
The current context |
start_time |
datetime or int nanoseconds since the epoch |
Now |
capture_input |
bool | None |
The init() value |
capture_output |
bool | None |
The init() value |
end_time takes the same types as start_time. Unlike the TypeScript SDK, start_span() has no kind or links argument, and parent does not accept a SpanHandle.
Token usage
usage takes a dictionary, typed as Usage, with these keys:
| Key | Attribute |
|---|---|
input_tokens |
gen_ai.usage.input_tokens |
output_tokens |
gen_ai.usage.output_tokens |
total_tokens |
gen_ai.usage.total_tokens |
cache_read_input_tokens |
gen_ai.usage.cache_read.input_tokens |
cache_creation_input_tokens |
gen_ai.usage.cache_creation.input_tokens |
reasoning_output_tokens |
gen_ai.usage.reasoning.output_tokens |
Some providers price text, image, and audio tokens differently. For those, usage also accepts per-modality counts. Each maps to gen_ai.usage.<modality>.<kind>, for example image_input_tokens to gen_ai.usage.image.input_tokens:
| Modality | Input | Output | Cache read |
|---|---|---|---|
| Text | text_input_tokens |
text_output_tokens |
text_cache_read_input_tokens |
| Image | image_input_tokens |
image_output_tokens |
image_cache_read_input_tokens |
| Audio | audio_input_tokens |
audio_output_tokens |
audio_cache_read_input_tokens |
Modality counts are parts of input_tokens and output_tokens, not additions to them. Send the totals too, and keep the sum of each kind’s modality counts at or below its total. See Cost for how the server prices modality counts.
The SDK drops unknown keys and logs a debug message.
Add user, session, and metadata
propagate_attributes() is a context manager. It adds correlation attributes to every span and log created inside its block:
from telemetry_dev import propagate_attributes
with propagate_attributes(
user_id="user_123",
session_id="session_456",
metadata={"tenant": "acme"},
):
handle_request()
| Argument | Attribute |
|---|---|
user_id |
user.id |
session_id |
gen_ai.conversation.id |
metadata |
td.metadata.<key> |
Blocks can nest. The inner block’s values replace outer values that have the same key. propagate_attributes() works before init().
One trace per session
A root span with a session ID joins its session’s trace. The SDK derives the trace ID from the API key and the session ID, so every root span in the same session lands in one trace, even across separate requests and processes. The derivation is the same as in the TypeScript SDK, so Python and TypeScript services that share a session ID and API key also share the trace. A span with a real parent, such as a child span or a span continued from a traceparent, keeps that parent.
Root spans without a session ID each start their own trace.
Process sessions
A command-line tool that holds one conversation per process may have no session ID to pass. Set session_mode="process" to have the SDK generate one:
telemetry_dev.init(session_mode="process")
The SDK generates a new random session ID each time init() turns the client on. It uses that ID only for spans and logs that have no session ID of their own, from propagate_attributes() or a gen_ai.conversation.id attribute. The ID ends when the client shuts down or a later init() replaces it. It is not saved between processes.
Do not use process sessions in a long-running server; they would put unrelated requests in one session. Pass a session ID per conversation with propagate_attributes() instead.
Send logs
log() records an OpenTelemetry log in the current trace context:
from telemetry_dev import log
log(
"model request complete",
level="info",
event_name="model.complete",
attributes={"attempt": 1, "cached": False},
)
| Argument | Type | Default |
|---|---|---|
message |
str |
Required |
level |
"debug" | "info" | "warn" | "error" |
"info" |
event_name |
str | None |
None |
attributes |
Mapping[str, Any] | None |
None |
"warning" is accepted as another name for "warn". An unknown level becomes "info".
The message goes through mask, serialization, and truncation, as described in Control captured content. Attributes are not masked. Numbers, booleans, strings, and lists whose items are all numbers, all booleans, or all strings are stored as attribute values, with strings truncated to max_attribute_length. Other values are stored as JSON strings. Propagated user, session, and metadata attributes are added to every log and replace caller attributes with the same key.
Unlike the TypeScript SDK, log() has no timestamp argument.
Continue a trace across services
get_traceparent() returns the W3C traceparent value for the current span, or None when no span is active. Send it to the downstream service, for example as a traceparent header. On the receiving side, pass it as parent:
from telemetry_dev import start_span
with start_span("downstream-work", parent=request.headers.get("traceparent")):
do_work()
parent accepts a traceparent string, an OpenTelemetry Context, or an OpenTelemetry SpanContext. If the string is not a valid traceparent, the SDK uses the current context instead.
Control captured content
By default the SDK records span input and output. Turn either off for all spans in init(), or for one span with the capture_input and capture_output arguments of @observe and start_span().
To redact content instead of dropping it, pass a mask function. This example keeps outputs but replaces every model input:
import telemetry_dev
from telemetry_dev import MaskContext
def mask(value, context: MaskContext):
if context.key == "gen_ai.input.messages":
return "[redacted]"
return value
telemetry_dev.init(mask=mask, max_attribute_length=16_384)
The SDK prepares each piece of captured content in three steps:
- It passes the value to
mask, if you set one. - It serializes the result as JSON. Strings are kept as they are.
- It truncates the string to
max_attribute_length. A truncated value ends with...[truncated], and the marker counts toward the limit. Length is counted in UTF-16 code units, so both SDKs cut the same text at the same point.
mask receives the content below. context.key is the attribute that the result will be written to:
| Content | context.key |
|---|---|
| Span input | gen_ai.input.messages, or gen_ai.tool.call.arguments on tool spans |
| Span output | gen_ai.output.messages, or gen_ai.tool.call.result on tool spans |
system_instructions |
gen_ai.system_instructions |
| Log message | log.message |
If mask raises, the SDK drops the value and reports the error to on_error. Returning None does not drop the value; it is recorded as the JSON string null.
The TypeScript SDK also masks log attributes. The Python SDK does not.
max_attribute_length also truncates string metadata and log attributes, with the same marker. Other string attributes are cut at the same length without a marker.
See Privacy for server-side capture and redaction controls.
Errors and fail-open behavior
The SDK never raises its own errors into your code. Failures during initialization, export, masking, and filtering go to on_error and the telemetry_dev logger. If on_error itself raises, the SDK logs that error at debug level and continues. flush() and shutdown() never raise.
Without an API key, or with enabled=False, every SDK function does nothing.
If span_filter raises, the SDK exports the span anyway and reports the error to on_error.
Share the provider with other instrumentation
By default the SDK’s tracer provider is private to the SDK. Set register_global=True when other OpenTelemetry instrumentation in the same process should send its spans through the SDK.
With global registration, the SDK exports only its own spans by default: those from the telemetry_dev instrumentation scope. Pass a span_filter to choose a different set. This one exports every span:
telemetry_dev.init(
register_global=True,
span_filter=lambda span: True,
)
Calling init() again shuts down the current client first, then replaces it.
Automatic metrics
The SDK turns ended spans into OpenTelemetry histograms and sends them to telemetry.dev every 60 seconds, with delta temporality:
| Metric | Unit | Recorded for |
|---|---|---|
gen_ai.client.operation.duration |
s |
Generation, agent, embedding, and tool spans |
gen_ai.client.token.usage |
{token} |
Generation, agent, and embedding spans with input or output token counts |
gen_ai.client.operation.time_to_first_chunk |
s |
Generation spans with time_to_first_chunk_ms |
gen_ai.client.operation.time_per_output_chunk |
s |
Generation spans with two or more recorded output chunks |
Plain "span" spans record no metrics. Spans that span_filter rejects record no metrics.
If you pass your own metric_reader, the SDK cannot produce gen_ai.client.operation.time_per_output_chunk, and it reports that once through on_error. The other metrics still work.
Measure streaming latency
For a streamed model response, record when output arrives. Set time_to_first_chunk_ms on the generation span, and call record_output_chunk() on its handle each time a non-empty chunk arrives:
import time
from telemetry_dev import start_span
with start_span("chat-stream", type="generation", model="gpt-4o") as span:
started = time.perf_counter()
first = True
for chunk in stream:
if first:
span.update(time_to_first_chunk_ms=(time.perf_counter() - started) * 1000)
first = False
span.record_output_chunk()
span.update(finish_reason="stop")
After the first chunk, each interval between chunks feeds the time_per_output_chunk histogram. The SDK times chunks when your loop receives them, so time your code spends between reads counts toward the interval. record_output_chunk() uses time.perf_counter() by default. If you pass your own timestamp_ms, use the same monotonic millisecond clock for every chunk of the span.
Flush and shut down
flush(timeout_s=10.0) sends pending traces, logs, and metrics. shutdown(timeout_s=10.0) sends pending data, releases resources, and clears the active client. Calling either one again is safe.
The SDK registers an atexit hook that calls shutdown() when the interpreter exits normally. Set disable_atexit=True to skip it.
Pick the pattern for your runtime:
- Long-running server. Keep the default batched mode. The
atexithook shuts the SDK down on a normal exit. - Short script or CLI. The
atexithook sends the last batch. Calltelemetry_dev.shutdown()yourself if the process might exit without runningatexithooks. - Serverless function. Use immediate export, and call
flush()before the runtime freezes:
telemetry_dev.init(export_mode="immediate")
# Handle the request.
telemetry_dev.flush()
Immediate mode applies to spans and logs. Metrics still export on a 60-second interval, so a serverless function must call flush() to send them.
Provider integrations
Use a provider package to capture model requests, responses, tokens, stream data, and provider errors.