Skip to content
telemetry.dev
Esc
↑↓navigate↵open⌘Jpreview
On this page

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-dev
uv add telemetry-dev

Create 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 as AccountStore.load.
  • Records the arguments as input: a dictionary of parameter names to values, without self and cls.
  • 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 W3C traceparent value, or None. In the TypeScript SDK, traceparent is a property, not a method.
  • span gives 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_reason becomes a one-item gen_ai.response.finish_reasons array.
  • time_to_first_chunk_ms is converted to seconds in gen_ai.response.time_to_first_chunk.
  • cost_usd overrides the server’s cost calculation. Without it, the server estimates cost from token usage when it has pricing for the model. See Cost.
  • metadata becomes td.metadata.<key> on this span. The SDK drops the keys userId, sessionId, user_id, and session_id; set the user and session with propagate_attributes() instead.
  • attributes are applied last and can replace mapped attributes.
  • error sets the span status to ERROR, sets error.type to the exception’s class name, and records an exception event 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:

  1. It passes the value to mask, if you set one.
  2. It serializes the result as JSON. Strings are kept as they are.
  3. 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 atexit hook shuts the SDK down on a normal exit.
  • Short script or CLI. The atexit hook sends the last batch. Call telemetry_dev.shutdown() yourself if the process might exit without running atexit hooks.
  • 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.

Last updated on October 18, 2026

Was this page helpful?