Skip to main content

asyncio-otlp-client

CI PyPI Python versions License: MIT

A pure-asyncio OpenTelemetry OTLP client for Python. Exports metrics, logs, and traces over OTLP/HTTP with no native dependencies — the core install needs only aiohttp.

The official opentelemetry-python OTLP exporters are synchronous (requests for HTTP, a blocking channel for gRPC) and always require protobuf. This library exists for asyncio applications that want neither.

Install

pip install asyncio-otlp-client              # JSON over HTTP; pure Python
pip install 'asyncio-otlp-client[protobuf]'  # binary protobuf over HTTP
pip install 'asyncio-otlp-client[grpc]'      # OTLP over gRPC (implies protobuf)

The JSON encoder is pure Python and always available. The protobuf encoder (used for http/protobuf and required for grpc) is imported lazily and only needs to be installed if you use it. This is why the default protocol is http/json rather than the http/protobuf the OTLP spec recommends: the default has to work on a core install, which depends on aiohttp alone. Set protocol explicitly and install the matching extra to put protobuf on the wire. Both encoders agree on the wire: they omit resource, scope, and status from the payload entirely when those carry no information, rather than sending an empty object.

Use

import time

from aiohttp import ClientSession
from otlp_client import OTLPClient, OTLPConfig, Resource, gauge

config = OTLPConfig(
    endpoint="http://localhost:4318",
    resource=Resource(attributes={"service.name": "my-app"}),
)

async with ClientSession() as session:
    client = await OTLPClient.create(config, session=session)
    await client.export_metrics(
        [gauge("temperature", 21.5, unit="Cel", time_unix_nano=time.time_ns())]
    )
    await client.aclose()

export_* performs one round trip, with retries. It returns Success or PartialSuccess, and raises OTLPPermanentError or OTLPTransportError on failure.

Batching

For fire-and-forget submission, wrap the client in a BatchProcessor. It owns a bounded queue per signal and a background flush task.

from otlp_client import BatchProcessor

async with BatchProcessor(client, max_batch=512, flush_interval=5.0) as proc:
    proc.submit_metrics([gauge("temperature", 21.5, time_unix_nano=time.time_ns())])

submit_* never blocks and never raises; it returns False if a record was dropped. Check proc.stats for submitted, exported, dropped, consecutive_failures, and last_error.

dropped counts every record that never reached the collector, regardless of cause: the bounded queue evicts the oldest record on overflow, and a batch that fails to export (a collector outage, for example) is discarded rather than requeued — telemetry here is best-effort with no on-disk durability, so a failed batch is a loss, not a retry candidate. last_error tells you which kind of loss happened last.

You don't have to poll stats to notice an outage: the first export failure in a run logs at WARNING, and consecutive failures after that log at DEBUG so a persistent outage doesn't spam your logs for as long as it lasts.

Trace context and exemplars

Spans and span links carry W3C trace_state and flags; build the flags with the helper rather than by hand:

from otlp_client import span, span_flags

s = span(
    "handle_request",
    trace_id=trace_id,
    span_id=span_id,
    start_time_unix_nano=start,
    end_time_unix_nano=end,
    trace_state="vendor=abc",
    flags=span_flags(sampled=True, is_remote=False),
)

is_remote=None (the default) means "unknown", which is distinct from is_remote=False: None leaves both context-related bits clear, while False sets the "is-remote is known" bit without setting "is-remote" itself.

Exemplars link a metric data point to the trace that produced it, so a spike is traceable back to the request behind it:

from otlp_client import Exemplar, NumberDataPoint

point = NumberDataPoint(
    time_unix_nano=now,
    value=1.5,
    exemplars=[Exemplar(time_unix_nano=now, value=1.5, trace_id=trace_id, span_id=span_id)],
)

A data point can also record that no measurement was taken, which is different from measuring zero. The gauge()/sum_() helpers don't expose flags, so set it on the dataclass directly:

from otlp_client import data_point_flags

point = NumberDataPoint(time_unix_nano=now, value=0, flags=data_point_flags(no_recorded_value=True))

Configuration

OTLPConfig is the only source of settings. To read the standard environment variables instead, opt in explicitly:

config = OTLPConfig.from_env()  # OTEL_EXPORTER_OTLP_*

TLS is configured via certificate_file (a CA to trust) and client_certificate_file / client_key_file (mutual TLS).

Two separate settings control transport security, and they are easy to confuse:

  • insecure decides whether TLS is used at all. It applies only to gRPC endpoints written without a scheme, and defaults to False.
  • insecure_skip_verify keeps TLS but stops verifying the server's certificate.

For gRPC, an explicit scheme always wins over insecure: https://host:4317 is TLS and http://host:4317 is plaintext, whatever insecure says. Only a scheme-less host:4317 consults insecure. OTLP/HTTP ignores insecure entirely and always uses the scheme in the endpoint.

Behaviour change. Before 0.3.0 a scheme-less gRPC endpoint such as collector.local:4317 connected in plaintext. It now uses TLS, matching the OTLP spec, whose insecure default is false. If you relied on the old behaviour, either write the scheme explicitly (http://collector.local:4317) or set insecure=True / OTEL_EXPORTER_OTLP_INSECURE=true.

insecure_skip_verify works over HTTPS. It does not work with protocol=OTLPProtocol.GRPC against a TLS endpoint — grpcio offers no API to disable certificate verification — and OTLPClient.create() raises OTLPConfigError there rather than silently connecting with verification still on.

Per-signal configuration

Headers, timeout and compression can be set per signal, via either the config fields or the OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_* environment variables.

A per-signal value replaces the general one — it does not merge with it. This follows the OTLP spec, and matches the other SDKs, but it is easy to get caught by:

export OTEL_EXPORTER_OTLP_HEADERS=api-key=secret
export OTEL_EXPORTER_OTLP_TRACES_HEADERS=x-tenant=acme

Traces are now sent with only x-tenant — the shared api-key is not included. Repeat any header the signal still needs:

export OTEL_EXPORTER_OTLP_TRACES_HEADERS=api-key=secret,x-tenant=acme

Setting a per-signal headers variable to the empty string sends no headers at all for that signal, which is distinct from leaving it unset.

PROTOCOL, INSECURE, CERTIFICATE, CLIENT_KEY and CLIENT_CERTIFICATE cannot vary per signal: one OTLPClient holds a single encoder and transport, and those settings choose them or configure the connection itself. from_env() raises OTLPConfigError on the per-signal forms rather than ignoring them. Use one client per signal if you need them to differ.

Dynamic credentials

Headers in OTLPConfig are fixed for the life of the client. When a credential rotates, pass a provider instead — it is awaited on every export attempt:

from otlp_client import OAuth2ClientCredentials, OTLPClient, OTLPConfig

credentials = OAuth2ClientCredentials(
    token_url="https://auth.example.com/oauth2/token",
    client_id="collector-writer",
    client_secret=secret,
    scope="otlp:write",
    session=session,  # required under Home Assistant
)
client = await OTLPClient.create(config, session=session, credentials=credentials)

Three helpers ship with the library:

Helper Sends Notes
BearerToken(source) authorization: Bearer … source is a token string, or an async callable consulted per attempt
BasicAuth(user, password) authorization: Basic … RFC 7617, UTF-8
OAuth2ClientCredentials(...) authorization: Bearer … Client-credentials grant, cached until it nears expiry

Anything else implements the protocol directly:

class VaultCredentials:
    async def headers(self, kind: SignalKind) -> Mapping[str, str]:
        return {"authorization": f"Bearer {await self.vault.read()}"}

    async def invalidate(self, kind: SignalKind) -> None:
        self.vault.clear()

Provider headers merge over configured ones, and the provider wins a key collision. This is the opposite of per-signal headers, which replace the general set. The two answer different questions: per-signal headers pick which static set applies, and the provider layers credentials on top of it. A static x-tenant alongside a rotating authorization therefore works as written.

A rejected credential earns one retry. On a 401, 403, or gRPC UNAUTHENTICATED, the client calls invalidate() and re-sends once, immediately, outside the retry budget. A second rejection is permanent — a wrong secret fails fast rather than hammering the token endpoint.

You own the provider's lifetime. OTLPClient.aclose() never closes a provider, because one provider shared across several clients (a first-class pattern when signals need different protocols or TLS settings) must outlive any one of them. Shut down in order: close clients and processors first, and the provider last. Using a provider after its aclose() raises a bare RuntimeError: Session is closed from aiohttp, so getting the order backwards breaks the very clients you closed it for. Call await credentials.aclose() yourself if the provider owns a session.

The OTLP specification defines no authentication concept at all, so there is no OTEL_EXPORTER_OTLP_* variable for any of this and OTLPConfig.from_env() cannot build a provider. Construct it in code.

Scope

This is a client, not an SDK. It owns the data model, encoding, transport, retry, and batching. It does not provide Tracer/Meter/Logger APIs, does not aggregate metrics, and does not instrument anything — you construct data points and hand them over.

The profiles signal is defined as a seam — SignalKind.PROFILES exists and carries its /v1development/profiles path — but no encoder implements it and no public export method accepts it yet: there is no export_profiles on OTLPClient, and submit_*/export_* cover metrics, logs, and traces only. The signal remains in development upstream.

Home Assistant

See docs/home-assistant.md. The core install is pure Python and publishes a py3-none-any wheel, so it installs on every Home Assistant architecture with no wheel-builder involvement.

Contributing

See CONTRIBUTING.md. make dev sets up the environment, make lint and make test are what CI runs.

License

MIT — see LICENSE.md.

Metadata

Release files for asyncio-otlp-client 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for asyncio-otlp-client 0.5.0
File Size Uploaded
asyncio_otlp_client-0.5.0.tar.gz 277.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for asyncio-otlp-client 0.5.0
File Interpreter ABI Platform
asyncio_otlp_client-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 321.8 kB

Release files / asyncio_otlp_client-0.5.0.tar.gz

Download URL asyncio_otlp_client-0.5.0.tar.gz
Size 277.3 kB
Tags Source
SHA-256 checksum
How to use checksums
b50469a748cbd13b0336de8d78a711735d823e3adf01abc653a8b32fb5a3c0cd
BLAKE2b-256 checksum
How to use checksums
4ae2d20f6f3e4044808bae2c931f85e070987ced2329645d9147db3ec4569b39
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 8, 2026.

Transparency log

Release files / asyncio_otlp_client-0.5.0-py3-none-any.whl

Download URL asyncio_otlp_client-0.5.0-py3-none-any.whl
Size 44.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8aca0433254c3a659868fb89bb33c5f2353a67c27775fdd32a264c74f1bd0617
BLAKE2b-256 checksum
How to use checksums
64ee47bbaa25d3d6ac78f21d2d1c0d4bda888e219ad8a11957eca8a286f32f6d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 8, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page