Metrics and events¶
See what your code does in production, with no dependencies: in-process call statistics, and an event stream you can forward to your monitoring system.
from python_wrap.metrics import count_calls
@count_calls(window=500)
def work(n: int) -> int:
if n < 0:
raise ValueError("negative")
return n * 2
work(1)
work(2)
try:
work(-1)
except ValueError:
pass
stats = work.stats
assert (stats.calls, stats.errors, stats.in_flight) == (3, 1, 0)
assert stats.last_duration >= 0
assert 0 <= stats.p50 <= stats.p95 <= stats.p99
work.reset()
assert work.stats.calls == 0
When to use it¶
count_callswhen you want call counts, error rate and p50/p95/p99 for one function, read from your own code or an endpoint.- Events when you want to know what the resilience decorators did (retries, cache hits, circuit changes, rejections) and forward it to Prometheus, OpenTelemetry, statsd or your logs.
- For per-call durations written to the log, use
timing. For arguments and results, use Logging.
count_calls¶
.stats is a live view with calls, errors, in_flight, last_duration, mean, p50, p95 and p99 (seconds). calls and errors count everything since the last reset(); the latency figures cover the last window finished calls (default 1000), failed ones included. Percentiles use the nearest-rank method. All of it is thread-safe.
async def functions work the same, timed across the whole awaited coroutine, and in_flight shows how many calls are running right now:
import asyncio
from python_wrap.metrics import count_calls
@count_calls
async def fetch(n: int) -> int:
await asyncio.sleep(0)
return n
async def main() -> None:
await asyncio.gather(*(fetch(i) for i in range(5)))
assert fetch.stats.calls == 5
asyncio.run(main())
Methods share statistics
On methods the statistics belong to the function, so they are shared by every instance. Cancellation counts as an error.
Events¶
Decorators publish Event(name, function, fields) objects. Subscribe to receive them:
from python_wrap.retry import retry
from python_wrap.events import Event, listening
seen: list[Event] = []
calls = 0
@retry(max_attempts=3, delay=0)
def flaky() -> str:
global calls
calls += 1
if calls < 3:
raise ConnectionError("down")
return "ok"
with listening(seen.append):
assert flaky() == "ok"
names = [e.name for e in seen]
assert names == ["retry.attempt_failed", "retry.attempt_failed", "retry.succeeded"]
assert seen[0].fields["attempt"] == 1
subscribe(listener)registers a listener and returns a function that unsubscribes it.listening(listener)is the same as awithblock.filter_events("retry.", listener)wraps a listener so it only sees names with that prefix (or any of a tuple of prefixes).
from python_wrap.events import Event, filter_events, subscribe
retries: list[Event] = []
unsubscribe = subscribe(filter_events("retry.", retries.append))
unsubscribe()
Listeners run in the calling thread
Listeners run synchronously in the thread that triggered the event, so keep them fast and never block. An exception in a listener is logged and swallowed: monitoring code can not break the decorated function. With no listeners registered, emitting is a single list check.
Event catalogue¶
python_wrap.events.EVENT_NAMES maps each name to a description; this table is the same data. Event.function is the decorated function's qualified name (for circuit.* events it is the breaker's name).
| Event | Fields |
|---|---|
retry.attempt_failed |
attempt (int, 1-based), exception (None for a rejected result), next_delay (seconds) |
retry.succeeded |
attempts (total made) |
retry.gave_up |
attempts, exception (None for a rejected result) |
retry.budget_exhausted |
attempts, exception: a shared RetryBudget refused the retry (retry.gave_up follows) |
timeout.expired |
seconds (the limit) |
circuit.state_changed |
old, new (closed, open, half_open) |
circuit.rejected |
retry_after (seconds) |
rate_limit.rejected |
retry_after (seconds) |
rate_limit.waited |
seconds |
throttle.skipped |
none |
cache.hit |
none |
cache.miss |
none |
cache.evicted |
reason (lru or ttl) |
cache.stale |
none (a stale value was served and a background refresh started) |
cache.refresh_failed |
exception (the stale value is kept) |
bulkhead.rejected |
active, waiting |
periodic.run |
run (1-based), seconds (how long the run took) |
periodic.failed |
run, exception |
from python_wrap.events import EVENT_NAMES
assert "cache.hit" in EVENT_NAMES
for name, description in EVENT_NAMES.items():
assert description
Recipes¶
None of these libraries are dependencies of python-wrap, so the blocks below are not run by the docs checker. Each is a few lines: subscribe once at start-up.
Prometheus¶
from prometheus_client import Counter
from python_wrap.events import Event, subscribe
_counters: dict[str, Counter] = {}
def to_prometheus(event: Event) -> None:
name = "python_wrap_" + event.name.replace(".", "_") + "_total"
counter = _counters.get(name)
if counter is None:
counter = _counters[name] = Counter(name, event.name, ["function"])
counter.labels(function=event.function).inc()
subscribe(to_prometheus)
Use a fixed set of names (for instance built from EVENT_NAMES) to avoid the lookup race on first use, and keep function as the only label: never label with exception messages.
OpenTelemetry¶
from opentelemetry import metrics, trace
from python_wrap.events import Event, subscribe
counter = metrics.get_meter("python_wrap").create_counter("python_wrap.events")
def to_otel(event: Event) -> None:
counter.add(1, {"event": event.name, "function": event.function})
span = trace.get_current_span() # annotate the active span, if any
if span.is_recording():
span.add_event(event.name, {"function": event.function})
subscribe(to_otel)
statsd¶
from statsd import StatsClient
from python_wrap.events import Event, subscribe
statsd = StatsClient()
def to_statsd(event: Event) -> None:
statsd.incr(f"python_wrap.{event.name}")
subscribe(to_statsd)
structlog or logging¶
import structlog
from python_wrap.events import Event, subscribe
log = structlog.get_logger("python_wrap.events")
def to_structlog(event: Event) -> None:
fields = {k: v for k, v in event.fields.items() if k != "exception"}
log.info(event.name, function=event.function, **fields)
subscribe(to_structlog)
With the standard library instead, call logging.getLogger("myapp.events").info("%s %s", event.name, event.function, extra={"fields": event.fields}).
See also¶
- Timing, Logging
- Retry, Circuit breaker and the other pages that list the events they emit
- API reference, events API