Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Telemetry in Python

Two questions, and they are adjacent rather than the same: did the document install, and did the store answer. A service that watches only the first cannot tell a store that went away from a configuration nobody has changed.

status = config.status()

if not status.is_healthy:
    log.warning("config has failed %d reloads", status.consecutive_failures)

Everything here is a snapshot of a handful of atomic loads: nothing is re-read, no source is touched, nothing can block. That is what makes it cheap enough to take on every scrape — and it is the same contract the Rust telemetry feature makes, through the same engine.

ConfigStatus

What config.status() hands back.

FieldTypeMeans
generationintModels installed since the process started; zero before the first
stale_forfloat | NoneSeconds since the serving model was installed. None — not zero — before the first install, because zero reads as just now
last_reasonstr | NoneWhy it was installed: initial, manual, file_changed, remote, replaced
last_failureFailure | NoneThe most recent reload that installed nothing
consecutive_failuresintReloads that installed nothing since one did. Zero is healthy
is_healthyboolconsecutive_failures == 0, decided by the engine rather than recomputed here

stale_for is the number most alerts are written against: this service's configuration has been stale for an hour is the page that matters, and a failing reload leaves the previous model serving — so staleness is what says so, not an outage.

RemoteStatus

What config.remote_status() hands back.

FieldTypeMeans
fetchesintDocuments the store handed over, pulled or pushed
stale_forfloat | NoneSeconds since the last one arrived
last_fetch_durationfloat | NoneHow long the last pulled fetch took. None again after a push: the previous pull's duration beside a push's timestamp would describe neither
last_failureFailure | NoneThe most recent fetch that returned nothing
consecutive_failuresintFetches that returned nothing since one did
reachablebool | NoneThree states. None before anything has been asked of the store at all — a source installed and never fetched is not down, and reporting it as down is how a scrape at startup pages somebody

It touches no engine, so asking a configuration that has never loaded does not fix its sources.

Failure

FieldTypeMeans
kindstrThe category: io, parse, missing, type, env, invalid, remote, auth, decrypt, backend — the names the exception classes carry
pathstrThe dotted key path, empty when the failure belongs to the load as a whole
seconds_agofloatHow long before the status was taken it was recorded

A failure is kept after a later success, because it is history; the health is consecutive_failures on the status holding it.

Why there are no timestamps

The engine records when with a monotonic clock, deliberately: those numbers are read as how long ago, and a wall clock stepping backwards under NTP would make a freshly loaded configuration look stale. A monotonic instant has no epoch to convert from — not Unix's, and not time.monotonic()'s, which is a different clock read from a different origin even inside one process.

So what crosses is elapsed seconds, measured when the status was taken. There is deliberately no loaded_at and no datetime: building one by subtracting from time.time() would claim a precision the engine refused to claim, and would be wrong in exactly the case the monotonic clock was chosen for. A service that needs a wall-clock timestamp takes one itself in an on_reload hook — that is its clock, and its decision.

Exposition: Prometheus text

from dynamic_config import Exposition

@app.get("/metrics")
def metrics() -> Response:
    body = Exposition().add("db", config).add_remote("db", config).render()

    return Response(body, media_type="text/plain; version=0.0.4")

Built per scrape and thrown away: every sample comes from a status, so there is nothing worth keeping between scrapes and nothing to go stale. This package chooses no metrics ecosystem for the service importing it — exactly as the Rust crate does not — so what it hands back is a string for whatever /metrics route you already have.

MethodEffect
add(name, config)The configuration's own series, labelled config="{name}"
add_with(labels, config)The same, with a label mapping of your choosing — an application and a profile, say
add_remote(name, config)The configuration's remote store, usually under the same name so the two halves join in a query
add_remote_with(labels, config)The same, with your labels
render()The body. Durations are measured here, so the seconds are as fresh as the response

Every method except render() returns the exposition, so the calls chain.

What it emits

Per configuration added:

NameTypeExtra label
dynamic_config_installs_totalcounter
dynamic_config_last_success_secondsgauge
dynamic_config_consecutive_failuresgauge
dynamic_config_last_failure_secondsgauge
dynamic_config_last_reload_infogaugereason
dynamic_config_last_failure_infogaugekind

Per remote source added:

NameTypeExtra label
dynamic_config_remote_upgauge
dynamic_config_remote_fetches_totalcounter
dynamic_config_remote_last_fetch_secondsgauge
dynamic_config_remote_last_fetch_duration_secondsgauge
dynamic_config_remote_consecutive_failuresgauge
dynamic_config_remote_last_failure_infogaugekind

These names are API. They end up in dashboards and alert rules, so a rename is a breaking change.

A fact that does not exist yet is absent rather than zero: no last_success_seconds before the first install, no remote_up before the store has been asked anything. An absent series is a gap in a graph; a zero is a claim.

What never becomes a label

No configured value, no key path, no store address. The only string a source can produce for itself is describe(), which is a URL and routinely carries user:password@host — so the name a series carries is yours, passed to add, and there is no overload that takes the store's own. A Failure's dotted path stops at the status object: the exposition renders the failure's category and never its path, which is unbounded label cardinality as well as a detail nobody asked to publish.

Cardinality is bounded and the bound is the caller's: six series per configuration per scrape, six more per remote source, with label sets from fixed enums — five reload reasons, ten error kinds. Label names are sanitised to Prometheus's [a-zA-Z_][a-zA-Z0-9_]* and values escaped, so no caller can break out of the exposition; the cardinality of what you pass stays your decision.

Health endpoints

The two statuses are what a readiness probe is made of, and they say different things:

@app.get("/readyz")
def readyz() -> Response:
    status = config.status()

    if config.try_current() is None:
        return Response("no configuration", status_code=503)
    if not status.is_healthy:
        return Response("configuration stale", status_code=503)

    return Response("ok")

Serving something and the last attempt worked are separate conditions, and a service that conflates them either refuses traffic it could serve or accepts traffic on a configuration nobody has been able to reload for an hour. try_current() answers the first without raising; is_healthy and stale_for answer the second.