Skip to main content

observe-kit

PyPI Python CI Coverage License PRs welcome

One decorator, @observed, that times a call, decides how it ended, writes a structlog line and emits an event to a sink you choose. It works on plain and async def functions and methods. Its only dependency is structlog.

pip install observe-kit
from observe_kit import observed


class Billing:
    def __init__(self, log, sink, notifier):
        self.log, self.sink, self.notifier = log, sink, notifier

    @observed("billing.charge", expected=(CardDeclined,), fields=("customer_id",))
    def charge(self, customer_id: str, cents: int) -> Receipt: ...

Each call to charge then produces one log line and one event:

billing.charge.finished   customer_id=c_42 duration_ms=183
billing.charge.expected   customer_id=c_42 duration_ms=95  error="CardDeclined: insufficient funds"

Four outcomes

Every call ends in exactly one of them. You decide which exceptions are which, so there is no bare except Exception: pass anywhere in your code.

outcome when logged at then
finished the call returned info (level="debug" for chatty calls) the value is returned
expected raised one of expected= warning re-raised for the caller
swallowed raised one of swallow= debug default= is returned
raised raised any other Exception error, with traceback the notifier is told, then re-raised

A type listed in both expected and swallow counts as expected. BaseExceptions that are not Exceptions (KeyboardInterrupt, SystemExit, asyncio.CancelledError) pass through untouched unless you list them. The re-raise is a bare raise, so tracebacks gain no frame from the decorator.

Arguments

@observed(
    "orders.ship",              # event root; the outcome is appended: orders.ship.finished
    expected=(OutOfStock,),     # normal operation, re-raised
    swallow=(KeyError,),        # "it wasn't there", replaced by default
    default=None,
    fields=("order_id",),       # argument names added to the log line and the event
    detail="carrier",           # one argument kept as a string on the event, not in the log context
    level="info",               # log level of `finished`
    notify_policy=POLICY,       # what a `raised` notification may carry (below)
)

fields and detail are resolved against the signature, so positional and keyword arguments both work. duration_ms is measured with perf_counter and, for async def, covers the whole await. Generators are refused at decoration time: the call returns before any work happens, so the timing would mean nothing. Decorate the function that consumes the generator instead.

Where the logger, sink and notifier come from

observed looks at the first argument (self for a method) for these attributes, and falls back to a default when one is missing or has the wrong shape:

attribute expected shape fallback
log a structlog logger (has .bind) structlog.get_logger(module)
sink an EventSink: emit(event) the configure()d sink, else NullSink, which drops events
notifier a Notifier: error(title, text) the configure()d notifier, else none; nobody is told
observe_context a mapping, e.g. {"run_id": 7} {}

observe_context is bound onto every log line and event from that instance. Plain functions get the fallbacks, so @observed on a module-level function just logs.

For fields that belong to a unit of work rather than an object, such as a request id or a run id, bind them around the block:

from observe_kit import bind

with bind(run_id=run.id, tenant=tenant):
    sync_accounts()  # every @observed call inside carries run_id and tenant

bind is backed by a ContextVar: it holds for the current thread, and an asyncio task sees what was bound where it was created. A new thread or thread-pool worker starts without it; run the work through contextvars.copy_context().run(...) to carry it over. Nested blocks merge, the inner value wins, and each block restores what it found. On a clash, the instance's observe_context beats bind, and the decorator's fields= beats both.

To give every call a sink and a notifier without threading them through each instance, configure them once at start-up, next to your structlog configuration:

import observe_kit

observe_kit.configure(sink=CountingSink(counter), notifier=Pager(), notify_policy=POLICY)

The instance's own sink and notifier still win; the configured ones fill the gaps, including for plain functions. They are read on every call, so configure can run after the modules that decorate are imported. It returns the previous defaults; observe_kit.restore(previous) puts them back, which is what a test wants. An argument left out keeps its value, None clears it.

Events and sinks

Each outcome becomes an ObservedEvent:

ObservedEvent(
    name="billing.charge",
    outcome=CallOutcome.EXPECTED,
    duration_ms=95,
    error="CardDeclined: insufficient funds",  # None when finished
    context={"customer_id": "c_42"},
    detail=None,
)
event.event  # "billing.charge.expected"

A sink is anything with emit(event). Write one that increments a Prometheus counter, inserts a row into a table, or pushes to a queue:

class CountingSink:
    def __init__(self, counter):
        self.counter = counter

    def emit(self, event):
        self.counter.labels(event.name, event.outcome).inc()

Testing

Installing observe-kit registers a pytest plugin with one fixture, observed_events: a fresh MemorySink configured as the process-wide sink for the test, and removed afterwards. Tests assert on what happened instead of parsing logs:

from observe_kit import CallOutcome


def test_declined_card_is_expected(observed_events):
    with pytest.raises(CardDeclined):
        charge("c_42", 500)

    event = observed_events.assert_one("billing.charge", CallOutcome.EXPECTED)
    assert event.context["customer_id"] == "c_42"

assert_one(name, outcome=None) returns the only matching event, or fails with every event that was recorded. named(name) returns them all, oldest first.

The fixture catches calls that have no sink of their own. An instance with a .sink keeps using it; give it a MemorySink directly:

def test_charge_on_billing():
    sink = MemorySink()
    billing = Billing(log=structlog.get_logger(), sink=sink, notifier=None)
    billing.charge("c_42", 500)
    sink.assert_one("billing.charge", CallOutcome.FINISHED)

Notifications and NotifyPolicy

On raised, the instance's notifier gets a title ("billing.charge raised TimeoutError") and a short text. Alerts end up in chat apps, phones and mailboxes, so the text is deliberately thin:

  • only context fields you allow travel; the rest are counted, never shown;
  • only the first line of the error travels; the lines after it are counted;
  • URLs in that line are replaced by <url withheld>.

The default policy allows no fields. Set your own once, for the whole process:

from observe_kit import NotifyPolicy, configure

POLICY = NotifyPolicy(
    fields=frozenset({"run_id", "count", "duration_ms"}),
    withheld_hint="see logs/app.jsonl",
)
configure(notify_policy=POLICY)

@observed(..., notify_policy=OTHER) overrides it for one function.

billing.charge raised TimeoutError

app.billing.Billing.charge
TimeoutError: Page.goto: Timeout 30000ms exceeded. (+2 line(s) withheld)
run_id=7 (1 field(s) withheld — see logs/app.jsonl)

The full error, traceback and context are still in the log line and the event; only the notification is trimmed.

Lineage

observe-kit is a rewrite of atlassian-labs/observe, which I wrote at Atlassian in 2020. It keeps the idea and the Apache-2.0 license; the code is new. See NOTICE.

License

Apache-2.0

Release files for observe-kit 0.2.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 observe-kit 0.2.0
File Size Uploaded
observe_kit-0.2.0.tar.gz 68.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for observe-kit 0.2.0
File Interpreter ABI Platform
observe_kit-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 90.5 kB

Release files / observe_kit-0.2.0.tar.gz

Download URL observe_kit-0.2.0.tar.gz
Size 68.9 kB
Tags Source
SHA-256 checksum
How to use checksums
f08c79c7c755b259c03c9697dfe8665e626b03b69db1ef44d778f691b70ed66d
BLAKE2b-256 checksum
How to use checksums
3692a2acd5ecca1d73409d47a5af52def28c514054e0b9a8859227aa3ab48c4a
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 25, 2026.

Transparency log

Release files / observe_kit-0.2.0-py3-none-any.whl

Download URL observe_kit-0.2.0-py3-none-any.whl
Size 21.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
318774d10e37b60c50d18fc2f4b1ead427ee6f2dfccb41aa4ddfa31fb2fc94fd
BLAKE2b-256 checksum
How to use checksums
23d4efdfd81b39e786356231e8be769f657204973daaeea9461ef2db62692f9f
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 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.1

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