Metrics
The metrics module records OpenTelemetry metrics for your app. Add one Metrics() component and grelmicro emits metrics from every built-in component. Add @measure to time your own functions. Register an OpsServer to serve a Prometheus endpoint on a port of its own.
Quick Start
import asyncio
from grelmicro import Grelmicro
from grelmicro.metrics import Metrics, measure
@measure(name="orders.process")
async def process(order_id: str) -> None:
pass
micro = Grelmicro(
uses=[
Metrics(
service_name="orders",
exporter="console",
export_interval=5,
),
]
)
async def main() -> None:
async with micro:
await process(order_id="ORD-1")
micro.metrics.counter("orders.placed", unit="1").add(1)
asyncio.run(main())
Metrics() installs an OpenTelemetry MeterProvider for the app's lifetime. The provider is installed on enter and restored to the prior global on exit, so sequential apps in tests do not stack providers.
Install
Metrics need the opentelemetry extra: pip install "grelmicro[opentelemetry]". See the installation guide for uv and poetry. Without the extra, the metric calls built into every component are no-ops, so an app that does not register Metrics() runs normally. Registering a Metrics() component does require the extra: it raises DependencyNotFoundError at startup when OpenTelemetry is missing.
Exporters
Pick an exporter with the exporter field or the GREL_METRICS_EXPORTER environment variable.
| Exporter | Use it for |
|---|---|
auto |
Exporting over OTLP HTTP when an endpoint is set, otherwise a no-op (default). |
otlp-http |
Sending metrics to an OpenTelemetry collector. |
otlp-grpc |
The same, over gRPC. |
prometheus |
Serving a /metrics endpoint that Prometheus scrapes. |
console |
Printing metrics to the console in development. |
none |
Installing the provider without exporting. |
Off until an endpoint is configured
Metrics() defaults to exporter="auto". It exports over OTLP HTTP when an endpoint is configured (the endpoint argument, OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, or OTEL_EXPORTER_OTLP_ENDPOINT) and otherwise auto-disables into a true no-op: it installs no meter provider and leaves the global provider untouched. So you register Metrics() unconditionally and it stays silent in dev, test, and CI instead of falling back to localhost:4318, and it never conflicts with a second app. A bounded shutdown_timeout (default 5.0 seconds) caps the flush on exit, so a slow or unreachable collector cannot hang shutdown.
For local development, set exporter="console" to print metrics, or exporter="prometheus" to expose them on a /metrics route. An explicit none is different from the auto-disable above: it still installs the provider so custom instruments record, they are just not exported. Leave the exporter on auto for the unconditional no-op.
Basic auth in one line
Backends like OpenObserve want an Authorization: Basic header. Pass basic_auth=(username, password) and Metrics builds and attaches it to the exporter:
Metrics(
service_name="orders",
endpoint="https://obs.example.com/api/default/v1/metrics",
basic_auth=("me@example.com", password),
)
From the environment, set GREL_METRICS_BASIC_AUTH_USERNAME and GREL_METRICS_BASIC_AUTH_PASSWORD instead. The header is attached on the exporter directly, so it bypasses the OTEL_EXPORTER_OTLP_HEADERS encoding where base64 padding (=) can be mangled or dropped.
The config never prints your credentials
An endpoint can embed credentials in its userinfo or query (https://usr:token@collector/v1), and header values are API keys. On MetricsConfig the endpoint field is a SecretUrl and each headers value is a SecretStr, so repr(), model_dump(), and model_dump_json() show *** in their place. The endpoint keeps its scheme, host, and path readable, so you can still see which collector is configured. Read either back with get_secret_value(). What grelmicro sends to the collector is unchanged.
Metrics() reads GREL_METRICS_* environment variables (see MetricsConfig for the full field set) or accepts the same fields as keyword arguments. The OTLP and Prometheus exporters require their own packages and are imported only when selected.
Environment variables are read by default
A GREL_* variable that fills a component field is read unless
GREL_ENV_LOAD=0. With GREL_ENV_LOAD=0, a variable that is set is
ignored, the default applies, and startup warns with its name. Passing
env_load=False to a component turns its reads off and stays quiet. See
How a value is resolved
for the three ways to configure a component, including a local .env.
Measure your own functions
@measure times a function and counts its calls. It works on sync and async functions.
from grelmicro.metrics import measure
@measure
async def charge_card(amount: int) -> None:
...
@measure(name="orders.checkout", record_in_flight=True)
async def checkout(cart_id: str) -> None:
...
@measure emits three metrics, named from the function or the name you pass:
<name>.duration: a histogram of seconds, carrying the same attributes as the call it timed.<name>.calls: a counter with agrelmicro.outcomeattribute set tosuccessorerror. On failure anerror.typeattribute carries the exception class name.<name>.active: an up_down_counter that rises while the function runs and falls when it returns. Only whenrecord_in_flight=True.
Every metric is a no-op when no Metrics component is active, so a decorated function is safe to ship even when metrics are off.
Custom instruments
The component builds OpenTelemetry instruments for you. Each accessor takes a unit and a description.
async with micro:
orders = micro.metrics.counter("orders.placed", unit="1")
orders.add(1, {"channel": "web"})
latency = micro.metrics.histogram("checkout.latency", unit="s")
latency.record(0.42)
in_flight = micro.metrics.up_down_counter("checkout.active", unit="1")
in_flight.add(1)
Use counter for values that only increase, up_down_counter for values that rise and fall, gauge for a last-known value, and histogram for distributions. Keep attribute keys bounded: a small fixed set like channel is fine, but never use unbounded values like user ids or cache keys.
Prometheus endpoint
With the prometheus exporter, register an OpsServer. It serves GET /metrics in the Prometheus exposition format on port 8080, next to the health probes, and away from the port your users reach:
from fastapi import FastAPI
from grelmicro import Grelmicro
from grelmicro.health import HealthChecks
from grelmicro.http import OpsServer
from grelmicro.metrics import Metrics
micro = Grelmicro(
uses=[
OpsServer(),
HealthChecks(provider_checks="require"),
Metrics(exporter="prometheus"),
]
)
app = FastAPI()
micro.install(app)
# Your routes on the app port (8000 with Uvicorn)
# GET /livez, /readyz, /healthz and /metrics on :8080
Point the Prometheus scrape at port 8080.
Keep /metrics private
The exposition names your routes (http.route), counts their errors by exception type, and reports every circuit breaker's state, every task and lock name, and every outbox topic. It is a map of your service and of what is failing in it right now. Never serve it on the internet. Read Secure the ops endpoints.
On the app port
When the scraper can reach only the app port, metrics_router() from grelmicro.integrations.fastapi adds the same GET /metrics route to your FastAPI app:
from fastapi import FastAPI
from grelmicro import Grelmicro
from grelmicro.integrations.fastapi import metrics_router
from grelmicro.metrics import Metrics
micro = Grelmicro(uses=[Metrics(exporter="prometheus")])
app = FastAPI()
micro.install(app)
app.include_router(metrics_router())
# Endpoint: GET /metrics
Every caller who reaches the app reaches this route too. Gate it with dependencies. Pass prefix and path to mount it elsewhere. The router resolves the default Metrics component from the running app, or you can pass one explicitly with metrics_router(component).
The route stays out of the OpenAPI schema. A scrape target is not part of your client contract, so /metrics is served but never published. Pass include_in_schema=True to document it, as the text exposition it returns rather than as JSON.
Without FastAPI
metrics_asgi() serves the same endpoint as a pure-ASGI app, so it mounts in
any ASGI framework:
from litestar import Litestar
from litestar.handlers import asgi
from grelmicro import Grelmicro
from grelmicro.metrics import Metrics, metrics_asgi
micro = Grelmicro(uses=[Metrics(exporter="prometheus")])
app = Litestar(
route_handlers=[asgi("/", is_mount=True, copy_scope=True)(metrics_asgi())]
)
micro.install(app)
# Endpoint: GET /metrics
A process that serves no HTTP at all registers an
OpsServer the same way:
micro = Grelmicro(uses=[OpsServer(port=8080), Metrics(exporter="prometheus")])
How every metric is named
Two rules cover the whole library, so a name is predictable before you look it up.
A metric is grelmicro.{component}.{what}. Where an OpenTelemetry
semantic convention already covers the measurement, the convention wins
instead: HTTP server metrics are http.server.*, not names of our own.
Every attribute grelmicro sets starts with grelmicro., except the
ones the conventions already own, such as error.type. Two attributes
appear everywhere:
| Attribute | Meaning |
|---|---|
grelmicro.outcome |
What happened. One word, the same word on every metric, so one filter reads across all of them. |
grelmicro.{namespace}.name |
Which instance. The namespace is the metric's own, so grelmicro.task.runs carries grelmicro.task.name and grelmicro.health.check.up carries grelmicro.health.check.name. |
grelmicro.outcome sits at the root rather than under each component on
purpose. It means the same thing everywhere, so a single query answers
"what is failing anywhere in grelmicro":
sum by (__name__) (
rate({__name__=~"grelmicro_.+_total", grelmicro_outcome="error"}[5m])
)
Anything else is grelmicro.{namespace}.{key}, such as
grelmicro.lock.mode or grelmicro.outbox.topic.
Prometheus renders the dots as underscores, so grelmicro.task.name
becomes the label grelmicro_task_name.
Built-in metrics
When a Metrics component is active, grelmicro emits these metrics from its
own components. All durations are histograms in seconds. All attributes are
bounded: component names are fixed at construction, never per-call keys or
ids. Every metric below also carries error.type when it reports a failure
that carried an exception, and never carries it on a success.
Tasks
| Metric | Type | Attributes |
|---|---|---|
grelmicro.task.runs |
counter | grelmicro.task.name, grelmicro.outcome |
grelmicro.task.duration |
histogram | grelmicro.task.name, grelmicro.outcome |
grelmicro.task.active |
up_down_counter | grelmicro.task.name |
grelmicro.task.schedule.delay |
histogram | grelmicro.task.name |
grelmicro.task.next_run |
gauge | grelmicro.task.name |
Coordination
| Metric | Type | Attributes |
|---|---|---|
grelmicro.lock.attempts |
counter | grelmicro.lock.name, grelmicro.lock.mode, grelmicro.outcome |
grelmicro.lock.extensions |
counter | grelmicro.lock.name, grelmicro.lock.mode, grelmicro.outcome |
grelmicro.lock.holders |
up_down_counter | grelmicro.lock.name, grelmicro.lock.mode |
grelmicro.leader_election.attempts |
counter | grelmicro.leader_election.name, grelmicro.outcome |
grelmicro.leader_election.leading |
gauge | grelmicro.leader_election.name |
Resilience
| Metric | Type | Attributes |
|---|---|---|
grelmicro.circuit_breaker.calls |
counter | grelmicro.circuit_breaker.name, grelmicro.outcome |
grelmicro.circuit_breaker.transitions |
counter | grelmicro.circuit_breaker.name, grelmicro.circuit_breaker.previous_state, grelmicro.circuit_breaker.state |
grelmicro.circuit_breaker.state |
gauge | grelmicro.circuit_breaker.name |
grelmicro.retry.attempts |
counter | grelmicro.retry.name, grelmicro.outcome |
grelmicro.retry.duration |
histogram | grelmicro.retry.name, grelmicro.outcome |
grelmicro.rate_limiter.calls |
counter | grelmicro.rate_limiter.name, grelmicro.outcome |
grelmicro.bulkhead.calls |
counter | grelmicro.bulkhead.name, grelmicro.outcome |
grelmicro.bulkhead.active |
up_down_counter | grelmicro.bulkhead.name |
grelmicro.timeout.exceeded |
counter | grelmicro.timeout.name |
grelmicro.shield.cache_writes |
counter | grelmicro.shield.name, grelmicro.outcome |
Cache, idempotency and outbox
| Metric | Type | Attributes |
|---|---|---|
grelmicro.cache.operations |
counter | grelmicro.cache.name, grelmicro.outcome |
grelmicro.cache.stale_serves |
counter | grelmicro.cache.name |
grelmicro.cache.early_refreshes |
counter | grelmicro.cache.name, grelmicro.outcome |
grelmicro.idempotency.operations |
counter | grelmicro.idempotency.name, grelmicro.outcome |
grelmicro.outbox.published |
counter | grelmicro.outbox.topic |
grelmicro.outbox.delivered |
counter | grelmicro.outbox.topic |
grelmicro.outbox.retried |
counter | grelmicro.outbox.topic |
grelmicro.outbox.dead_lettered |
counter | grelmicro.outbox.topic |
grelmicro.outbox.handler_duration |
histogram | grelmicro.outbox.topic |
Security
| Metric | Type | Attributes |
|---|---|---|
grelmicro.authentication.attempts |
counter | grelmicro.outcome, error.type and http.route when refused |
grelmicro.authorization.refusals |
counter | error.type, http.route |
grelmicro.client_bans.started |
counter | grelmicro.client_bans.name |
grelmicro.client_bans.active |
gauge | grelmicro.client_bans.name |
Read Security events for what each refusal records.
Health
| Metric | Type | Attributes |
|---|---|---|
grelmicro.health.check.up |
gauge | grelmicro.health.check.name, grelmicro.health.check.critical |
grelmicro.health.check.duration |
histogram | grelmicro.health.check.name, grelmicro.outcome |
The grelmicro.circuit_breaker.state gauge maps states to codes: CLOSED is
0, OPEN is 1, HALF_OPEN is 2, FORCED_OPEN is 3, FORCED_CLOSED is 4.
Requests
On a FastAPI, Starlette or Litestar app, every request is measured and Metrics exports it. FastAPI measures its own, and grelmicro measures Starlette and Litestar the same way.
| Metric | Type | Attributes |
|---|---|---|
http.server.request.duration |
histogram | http.request.method, http.route, http.response.status_code, url.scheme, network.protocol.version, error.type on a failure |
http.server.active_requests |
up_down_counter | http.request.method, url.scheme |
A URL excluded from tracing, see Tracing, is not measured either.
What grelmicro.outcome says
The values are drawn from one vocabulary, so a word means the same thing wherever it appears.
| Value | Where it appears | What it means |
|---|---|---|
success |
every metric that runs something | the work ran and returned |
error |
every metric that runs something | the work raised, and error.type names the exception |
admitted |
rate limiter, bulkhead | the call was let through |
rejected |
rate limiter, bulkhead, circuit breaker | the call was refused before it ran |
hit / miss |
cache | the read found an entry, or did not |
execute / replay |
idempotency | the request ran, or the stored response was served |
acquired |
locks, leader election | this worker took it |
unavailable |
locks, leader election | another worker holds it, which is not an error |
lost |
lock extensions | the lease was gone before the work finished |
skipped / missed / coordination_error |
tasks | see the fire table below |
refused |
authentication | the request was refused, and error.type says why |
Every admission primitive answers the same query, so the refusal rate of a rate limiter, a bulkhead and a circuit breaker are all read the same way:
sum by (__name__) (
rate({__name__=~"grelmicro_.+_total", grelmicro_outcome="rejected"}[5m])
)
Name your caches
TTLCache carries a name, and it is what separates one cache's hit rate
from another's. A cache built through the component inherits the
component's name, so name each cache when an app builds more than one:
sessions = micro.cache.ttl(name="sessions", ttl=60)
reference = micro.cache.ttl(name="reference", ttl=3600)
The caches grelmicro builds for you are named too, so they never land
in the same series as yours: Idempotency and CachedResponses report
under their own registration name, and a private @cached(ttl=...) cache
reports under the function it serves.
The name is a metric label, not a configuration address: a TTLCache
reads no environment variable, whatever it is called.
Watching a scheduled task
Three metrics answer the three questions a scheduled task raises.
Is it running at all? grelmicro.task.next_run is the instant of the
next fire, as a Unix timestamp, so subtracting now gives the time left. A
task whose next run has been in the past for longer than its period is a
task that stopped:
time() - grelmicro_task_next_run_seconds > 300
Is it running on time? grelmicro.task.schedule.delay is the distance
from the instant a fire was due to the instant the body started. It rises
when a worker is saturated, and it is the whole replay age on a fire that
came back after a restart.
Is it running everywhere it should?
grelmicro.leader_election.leading reads 1 on the leader and 0 on every
standby, so a healthy fleet sums to exactly one. Two is a split brain and
zero means nobody is running the leader-gated work:
sum by (grelmicro_leader_election_name) (grelmicro_leader_election_leading) != 1
A split brain is two replicas that each believe they lead.
Every fire lands on grelmicro.task.runs
Every fire a worker evaluates is counted once, whatever happens to it.
The grelmicro.outcome attribute says what:
grelmicro.outcome |
What happened |
|---|---|
success |
the body ran and returned |
error |
the body raised, error.type names the exception |
skipped |
another worker handled this fire, so this one stood down |
missed |
the fire was dropped and no worker ran it, either because it came back too late to replay or because the worker that claimed it could not admit the body |
coordination_error |
the fire never reached the body because coordination failed, error.type names the exception |
coordination_error is the one to alert on. It means the schedule backend
or the lock is unreachable, so the task is not running anywhere and its
own error rate stays at zero because the body never ran.
Watch missed too. A fire dropped past its grace budget ran on no worker
at all, which a skipped series cannot tell you.
Filter on grelmicro.outcome="success" for "how often does my task
actually run".
The bare total counts every fire each worker saw, so on a fleet of N
workers sharing a lock it is roughly N times the number of fires.
The startup catch-up tick is silent when it finds nothing to replay, and so is the first sight of a schedule, which records a baseline once so that later fires can be told apart from fires that never happened. Neither is a fire the worker had to act on.
Watching a distributed lock
A lock that stops working stops the work under it, quietly. Three signals tell the three failures apart.
grelmicro.lock.attempts with grelmicro.outcome="error" means the
backend is unreachable. Nothing is running anywhere, and the task's own
error rate stays at zero because no body ever ran.
The same counter with unavailable is contention, not failure. A blocking
acquire polls, so one point per poll is the normal shape of a busy lock.
grelmicro.lock.extensions with lost is the one to page on. The lease
expired while the work under it was still running, so a second worker may
already hold the lock and the at-most-once guarantee is gone. Raise
lease_duration above the work's real duration.
Background work always reports its failure
Work that runs outside your call stack has nowhere to raise. A cache cleanup sweep, an outbox relay, a lease extension or a background refresh cannot hand you an exception, so grelmicro guarantees that each one instead becomes observable, through at least one of:
- a counter you can alert on,
- a log record at
WARNINGor above, - a health check that degrades.
None of them is ever suppressed silently. A component that stops working while still reporting success is worse than one that fails loudly, so the two counters above exist precisely for the cases with no caller to tell.
grelmicro.cache.early_refreshes counts the background refresh early=
schedules, not the refresh() method and not a cold-miss recompute, which
both have a caller to raise into.
Both counters carry grelmicro.outcome and, on a failure, error.type, so an
error rate is derivable rather than only an absolute count. That
follows the same shape as grelmicro.task.runs, and it is why success and
failure share one counter instead of having their own.
A task fire that never reaches the body follows the same rule. A
coordination failure counts as coordination_error and a dropped fire as
missed, both on grelmicro.task.runs, so a task that stopped running
because its backend is down is never mistaken for a task with nothing to
do.
Watch the grelmicro.outcome="error" series on
grelmicro.cache.early_refreshes and
grelmicro.shield.cache_writes. Neither failure shows up in your latency
or error rate, because neither has a caller to fail:
- a failing early refresh means every hot key falls back to a cold miss when its entry expires,
- a failing shield cache write means there is no stored copy to serve when the primary next fails, which you would otherwise discover during the incident the shield exists for.
Both warnings name the cache key. A default key is a hash, but a key_template=
or a custom key= function puts argument values in it, so those
values reach the log line.