Skip to content

Metrics

See the Instrumentation & metrics guide for usage.

asyncly.client.metrics.sinks.base.MetricsSink

Bases: Protocol

Protocol for metrics backends used by InstrumentableHttpClient.

Implement observe_request to record completed requests in any backend.

on_request_start / on_request_end are optional: they bracket the in-flight window (increment on start, decrement on end). The client detects their presence once, when the sink is enabled, and only calls them if both are defined — so an existing sink that implements only observe_request keeps working unchanged. New sinks can inherit BaseMetricsSink to get no-op defaults for the optional hooks.

observe_request

observe_request(
    *,
    client: str,
    method: str,
    route: str,
    operation: str,
    status: int | str,
    outcome: str,
    duration_seconds: float,
    error_type: str | None = None,
) -> None

Record a single completed request.

Parameters:

Name Type Description Default
client str

The client name (client_name).

required
method str

HTTP method.

required
route str

Normalized, low-cardinality route label.

required
operation str

Logical operation name; falls back to route.

required
status int | str

Response status code, or a string marker on error.

required
outcome str

One of response, timeout, network_error, cancelled.

required
duration_seconds float

Total time including response handling.

required
error_type str | None

Normalized error taxonomy value on failure, else None.

None
Source code in asyncly/client/metrics/sinks/base.py
def observe_request(
    self,
    *,
    client: str,
    method: str,
    route: str,
    operation: str,
    status: int | str,
    outcome: str,
    duration_seconds: float,
    error_type: str | None = None,
) -> None:
    """Record a single completed request.

    Args:
        client: The client name (`client_name`).
        method: HTTP method.
        route: Normalized, low-cardinality route label.
        operation: Logical operation name; falls back to `route`.
        status: Response status code, or a string marker on error.
        outcome: One of ``response``, ``timeout``, ``network_error``,
            ``cancelled``.
        duration_seconds: Total time including response handling.
        error_type: Normalized error taxonomy value on failure, else None.
    """
    ...

on_request_start

on_request_start(
    *, client: str, method: str, route: str, operation: str
) -> None

Signal a request is about to be issued (in-flight increment).

Source code in asyncly/client/metrics/sinks/base.py
def on_request_start(
    self, *, client: str, method: str, route: str, operation: str
) -> None:
    """Signal a request is about to be issued (in-flight increment)."""
    ...

on_request_end

on_request_end(
    *, client: str, method: str, route: str, operation: str
) -> None

Signal a request has finished (in-flight decrement).

Source code in asyncly/client/metrics/sinks/base.py
def on_request_end(
    self, *, client: str, method: str, route: str, operation: str
) -> None:
    """Signal a request has finished (in-flight decrement)."""
    ...

observe_phase

observe_phase(
    *,
    client: str,
    operation: str,
    phase: str,
    seconds: float,
) -> None

Record the duration of a network phase (dns, connect, ttfb, ...).

Fed by the aiohttp TraceConfig from build_trace_config. Optional: the client only wires trace context through when the sink defines this method.

Source code in asyncly/client/metrics/sinks/base.py
def observe_phase(
    self, *, client: str, operation: str, phase: str, seconds: float
) -> None:
    """Record the duration of a network phase (dns, connect, ttfb, ...).

    Fed by the aiohttp `TraceConfig` from
    [`build_trace_config`][asyncly.client.metrics.trace_config.build_trace_config].
    Optional: the client only wires trace context through when the sink
    defines this method.
    """
    ...

asyncly.client.metrics.sinks.base.BaseMetricsSink

Convenience base with no-op optional hooks for sinks authored here.

Note

This is a convenience for new sinks. It is not the backward-compatibility mechanism: because MetricsSink is a structural Protocol, an external sink that implements only observe_request inherits nothing from this class. Compatibility is guaranteed at the call site, where InstrumentableHttpClient feature-detects the optional hooks before calling them.

asyncly.client.metrics.sinks.noop.NoopSink

Bases: BaseMetricsSink

The default sink: records nothing and adds no overhead.

asyncly.client.metrics.sinks.prometheus.PrometheusSink

PrometheusSink(
    namespace: str = "http",
    subsystem: str = "client",
    buckets: Iterable[float] = (
        0.005,
        0.01,
        0.025,
        0.05,
        0.1,
        0.25,
        0.5,
        1.0,
        2.5,
        5.0,
        10.0,
    ),
    phase_buckets: Iterable[float] = _DEFAULT_PHASE_BUCKETS,
    registry: CollectorRegistry = REGISTRY,
)

Bases: BaseMetricsSink

Metrics sink that records to Prometheus (needs the prometheus extra).

Exposes:

  • {prefix}_request_seconds — duration histogram, labeled by client, method, route, operation, and a compact outcome (response or error). status is intentionally kept off the histogram: each extra label multiplies the number of time series by the bucket count.
  • {prefix}_requests_total — request counter, labeled by client, method, route, operation, status, and the full outcome.
  • {prefix}_errors_total — error counter, labeled by client, method, route, operation, and normalized error_type.
  • {prefix}_in_flight — gauge of in-progress requests.

Parameters:

Name Type Description Default
namespace str

Prometheus metric namespace prefix. Defaults to http so the metric names match the http_client_* convention shared with the OpenTelemetry sink. Set namespace="asyncly" to restore the historical asyncly_client_* names.

'http'
subsystem str

Prometheus metric subsystem prefix.

'client'
buckets Iterable[float]

Histogram bucket boundaries in seconds.

(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
registry CollectorRegistry

Collector registry to register the metrics on.

REGISTRY
Source code in asyncly/client/metrics/sinks/prometheus.py
def __init__(
    self,
    namespace: str = "http",
    subsystem: str = "client",
    buckets: Iterable[float] = (
        0.005,
        0.01,
        0.025,
        0.05,
        0.1,
        0.25,
        0.5,
        1.0,
        2.5,
        5.0,
        10.0,
    ),
    phase_buckets: Iterable[float] = _DEFAULT_PHASE_BUCKETS,
    registry: CollectorRegistry = REGISTRY,
) -> None:
    metric_prefix = f"{namespace}_{subsystem}"
    self._latency = Histogram(
        f"{metric_prefix}_request_seconds",
        "HTTP client request duration including handler",
        ("client", "method", "route", "operation", "outcome"),
        buckets=tuple(buckets),
        registry=registry,
    )
    self._total = Counter(
        f"{metric_prefix}_requests_total",
        "Total HTTP client requests",
        ("client", "method", "route", "operation", "status", "outcome"),
        registry=registry,
    )
    self._errors = Counter(
        f"{metric_prefix}_errors_total",
        "Total HTTP client errors",
        ("client", "method", "route", "operation", "error_type"),
        registry=registry,
    )
    self._in_flight = Gauge(
        f"{metric_prefix}_in_flight",
        "In-progress HTTP client requests",
        ("client", "method", "route", "operation"),
        registry=registry,
        multiprocess_mode="livesum",
    )
    self._phase = Histogram(
        f"{metric_prefix}_phase_duration_seconds",
        "HTTP client network phase duration",
        ("client", "operation", "phase"),
        buckets=tuple(phase_buckets),
        registry=registry,
    )

asyncly.client.metrics.sinks.prometheus.PrometheusPoolCollector

PrometheusPoolCollector(
    *,
    upstream: str,
    namespace: str = "http",
    subsystem: str = "client",
)

Bases: Collector

Exposes aiohttp TCPConnector pool stats as Prometheus gauges.

Emits {prefix}_pool_connections{upstream, state} for active and idle connections. Bind it to a connector once the session exists::

collector = PrometheusPoolCollector(upstream="bybit")
collector.bind(session.connector)
Warning

This reads private aiohttp attributes (_acquired, _conns), which may change between aiohttp versions. Access is guarded: if the internals are absent, the collector degrades to emitting nothing rather than raising during a scrape.

Note

Pool stats are connector-scoped, not client-scoped: a connector shared by several clients cannot attribute connections to one of them. The upstream label names the connector, not a single operation.

Source code in asyncly/client/metrics/sinks/prometheus.py
def __init__(
    self,
    *,
    upstream: str,
    namespace: str = "http",
    subsystem: str = "client",
) -> None:
    self._upstream = upstream
    self._metric_name = f"{namespace}_{subsystem}_pool_connections"
    self._connector: Any = None

bind

bind(connector: Any) -> None

Attach the connector whose pool stats should be scraped.

Source code in asyncly/client/metrics/sinks/prometheus.py
def bind(self, connector: Any) -> None:
    """Attach the connector whose pool stats should be scraped."""
    self._connector = connector

asyncly.client.metrics.sinks.opentelemetry.OpenTelemetrySink

OpenTelemetrySink(meter: Meter)

Bases: BaseMetricsSink

Metrics sink backed by OpenTelemetry (needs the opentelemetry extra).

Records request counts, durations, errors, and in-flight requests through the given Meter. Metric names match the Prometheus sink defaults (http_client_*). status is kept off the duration histogram; the counter carries it.

Parameters:

Name Type Description Default
meter Meter

An OpenTelemetry Meter to create instruments from.

required
Source code in asyncly/client/metrics/sinks/opentelemetry.py
def __init__(self, meter: Meter) -> None:
    self._req_counter = meter.create_counter(
        name="http_client_requests_total",
        unit="1",
        description="Total HTTP client requests",
    )
    self._req_hist = meter.create_histogram(
        name="http_client_request_seconds",
        unit="s",
        description="HTTP client request duration including handler",
    )
    self._err_counter = meter.create_counter(
        name="http_client_errors_total",
        unit="1",
        description="Total HTTP client errors",
    )
    self._in_flight = meter.create_up_down_counter(
        name="http_client_in_flight",
        unit="1",
        description="In-progress HTTP client requests",
    )
    self._phase_hist = meter.create_histogram(
        name="http_client_phase_duration_seconds",
        unit="s",
        description="HTTP client network phase duration",
    )

asyncly.client.metrics.trace_config.build_trace_config

build_trace_config(sink: MetricsSink) -> TraceConfig

Build an aiohttp TraceConfig that reports network phases to sink.

Attach the result to a ClientSession(trace_configs=[...]). Phases are only emitted for requests carrying a trace_request_ctx with client and operation keys — which InstrumentableHttpClient sets automatically.

Reused connections skip the dns/connect phases entirely (aiohttp fires no callback), so no misleading zero-duration samples are recorded.

Source code in asyncly/client/metrics/trace_config.py
def build_trace_config(sink: MetricsSink) -> TraceConfig:
    """Build an aiohttp `TraceConfig` that reports network phases to ``sink``.

    Attach the result to a `ClientSession(trace_configs=[...])`. Phases are only
    emitted for requests carrying a ``trace_request_ctx`` with ``client`` and
    ``operation`` keys — which `InstrumentableHttpClient` sets automatically.

    Reused connections skip the dns/connect phases entirely (aiohttp fires no
    callback), so no misleading zero-duration samples are recorded.
    """
    observe = getattr(sink, "observe_phase", None)
    trace_config = TraceConfig()
    if observe is None:
        # Sink can't record phases — return an inert TraceConfig so callers can
        # still wire it in unconditionally.
        return trace_config

    tracer = _PhaseTracer(observe)
    trace_config.on_request_start.append(tracer.on_request_start)
    trace_config.on_dns_resolvehost_start.append(tracer.on_dns_start)
    trace_config.on_dns_resolvehost_end.append(tracer.on_dns_end)
    trace_config.on_connection_queued_start.append(tracer.on_pool_start)
    trace_config.on_connection_queued_end.append(tracer.on_pool_end)
    trace_config.on_connection_create_start.append(tracer.on_connect_start)
    trace_config.on_connection_create_end.append(tracer.on_connect_end)
    trace_config.on_response_chunk_received.append(tracer.on_chunk_received)
    trace_config.on_request_end.append(tracer.on_request_end)
    return trace_config

asyncly.client.metrics.taxonomy.classify_exception

classify_exception(exc: BaseException) -> tuple[str, str]

Return (outcome, error_type) for a failed request.

A successful request never reaches this function; the caller reports ("response", "none") directly.

The isinstance ladder is ordered most-specific first because the aiohttp hierarchy overlaps: ServerTimeoutError is both a ClientConnectionError and a TimeoutError, and ClientConnectorSSLError is a subclass of ClientConnectorError which is a subclass of ClientOSError. Reordering these branches silently changes the emitted labels.

Source code in asyncly/client/metrics/taxonomy.py
def classify_exception(exc: BaseException) -> tuple[str, str]:
    """Return ``(outcome, error_type)`` for a failed request.

    A successful request never reaches this function; the caller reports
    ``("response", "none")`` directly.

    The isinstance ladder is ordered most-specific first because the aiohttp
    hierarchy overlaps: ``ServerTimeoutError`` is both a ``ClientConnectionError``
    and a ``TimeoutError``, and ``ClientConnectorSSLError`` is a subclass of
    ``ClientConnectorError`` which is a subclass of ``ClientOSError``. Reordering
    these branches silently changes the emitted labels.
    """
    # CancelledError is a BaseException, not an Exception — check it first and
    # separately so a caller's broad ``except Exception`` can never swallow it.
    if isinstance(exc, asyncio.CancelledError):
        return CANCELLED, NONE

    # Timeouts. ConnectionTimeoutError (a ServerTimeoutError subclass) is a
    # timeout while establishing the connection, so tag it as a connect problem.
    if isinstance(exc, ConnectionTimeoutError):
        return TIMEOUT, CONNECT_ERROR
    if isinstance(exc, ServerTimeoutError | TimeoutError):
        return TIMEOUT, READ_TIMEOUT

    # TLS problems sit above the generic connector branch.
    if isinstance(
        exc,
        ClientConnectorSSLError | ClientConnectorCertificateError | ClientSSLError,
    ):
        return NETWORK_ERROR, TLS_ERROR

    # DNS resolution failures (also a ClientConnectorError subclass).
    if isinstance(exc, ClientConnectorDNSError) or _is_dns_error(exc):
        return NETWORK_ERROR, DNS_ERROR

    if isinstance(exc, ClientConnectorError):
        return NETWORK_ERROR, CONNECT_ERROR

    if isinstance(exc, ServerDisconnectedError):
        return NETWORK_ERROR, SERVER_DISCONNECTED

    # Connection reset — either the builtin OSError or an aiohttp ClientOSError
    # carrying ECONNRESET.
    is_reset = getattr(exc, "errno", None) == errno.ECONNRESET
    if isinstance(exc, ConnectionResetError) or (
        isinstance(exc, ClientOSError) and is_reset
    ):
        return NETWORK_ERROR, CONNECTION_RESET

    if isinstance(exc, ClientPayloadError):
        return NETWORK_ERROR, PAYLOAD_ERROR

    return NETWORK_ERROR, OTHER

asyncly.client.metrics.route_resolver.default_route_resolver

default_route_resolver(url: URL) -> str

Normalize a URL path into a low-cardinality route label.

Numeric and UUID-like path segments are replaced with :id so that, for example, /cats/42 and /cats/7 both map to /cats/:id. Used as the default route label for client metrics.

Source code in asyncly/client/metrics/route_resolver.py
def default_route_resolver(url: URL) -> str:
    """Normalize a URL path into a low-cardinality route label.

    Numeric and UUID-like path segments are replaced with ``:id`` so that, for
    example, ``/cats/42`` and ``/cats/7`` both map to ``/cats/:id``. Used as the
    default route label for client metrics.
    """
    parts: list[str] = []
    for p in url.path.split("/"):
        if not p:
            continue
        if p.isdigit() or (len(p) in (8, 16, 32, 36) and any(ch.isalpha() for ch in p)):
            parts.append(":id")
        else:
            parts.append(p)
    return "/" + "/".join(parts) if parts else "/"