Ad-hoc tracing
The same bindings that drive tests can stream a live, structured trace out of a running application. Nothing about a binding is test-specific: it observes a call site and emits events, and what happens to those events is decided by whoever is listening. This page covers that listening side.
The running example throughout is the inherited dev server: an
application you did not write and would rather not modify, whose
behaviour you need to see. The page builds the answer up in layers:
the sink protocol and its two registration tiers; a small library of
sinks that print, count and stream; narrowing at the binding with
when=; streaming to disk as JSON Lines; the TOML config file and
the python -m wrapture runner that make the whole intervention
zero-code; and the exporters that render a recorded trace for
Perfetto, snapshot comparisons and Mermaid diagrams.
Sinks: where events go
Binding points emit events; sinks consume them. The recording gate is
“is anything listening”, not “is there a timeline”: a Tape scoped to
a test is one kind of listener, and a sink registered for the life of
the process is another. When nothing is listening, an applied binding
constructs no event at all, so a bound but unmonitored call site costs
almost nothing beyond wrapt’s own dispatch.
A sink subclasses wrapture.Sink and overrides the notifications it
cares about:
class Sink:
def on_enter(self, event): ... # recorded, operation not yet run
def on_exit(self, event): ... # operation completed
def on_error(self, event): ... # operation raised
def flush(self): ... # push buffered output out
Each event is heard at most twice: on_enter when it is recorded, with
its position (sequence number, depth, parent link) already assigned and
its outcome fields still unset, then exactly one of on_exit or
on_error when the operation completes or raises. The event’s kind
field says what was observed (a call, or an attribute get, set or
delete), so the protocol stays three notifications however many kinds
exist. An event that is never closed, such as a generator abandoned
mid-iteration, gets an enter and no exit, which is itself information.
Two properties of delivery matter to sink authors:
Notifications run inline, on the thread that executed the observed operation, so they should be quick. Code a sink calls is never itself recorded, so a sink can safely touch observed objects without recording recursively.
A sink can never take the application down. An exception raised from a notification is suppressed, counted on the sink’s
errorsattribute, and reported with aSinkErrorWarningthe first time only, so a sink broken in a hot loop cannot flood the warnings.
A sink also declares, through capture_args and capture_result, how
much of each event’s values it needs, using the same levels the
capture policies define. The
effective level for a recorded event is the highest any active sink
declares, so a test’s tape (which declares "reference") never
downgrades what a streaming sink sharing the process needs, and a
binding’s own capture= override still beats them all.
Process and scoped listening
Sinks are registered on one of two tiers, and the difference is who can hear:
Scoped sinks live in a context variable. A timeline’s tape is one: entering
with timeline(...)pushes the tape, exiting removes it, and the tape is visible only to the context that opened it. That is what keeps one test’s recording out of another’s, and it propagates the way context does: into asyncio tasks, but not into threads.Process sinks are registered with
add_sink()and hear every recorded event, from every thread and task, untilremove_sink(). This is the tier for observing a whole running application, where there is no enclosing scope.
printer = wrapture.add_sink(wrapture.Printer())
...
wrapture.remove_sink(printer)
Because the process tier is global rather than context-bound, a worker thread’s calls reach it with nesting intact, even though the same calls are invisible to a timeline; the thread limitation is a property of scoped recording, not of recording itself.
At interpreter exit wrapture delivers everything it still owes:
flush() is called on every process sink still registered, so the
tail of a trace, usually the interesting part, is not lost in a
sink’s buffers, and any window with a run open closes
it and writes its reports. wrapture.shutdown() performs exactly
that operation on demand, and it is the one call to know for
environments that tear the interpreter down without ever running
atexit callbacks: embedded interpreters and subinterpreters are
destroyed by their host, and hosting platforms typically offer their
own shutdown notification instead. Under mod_wsgi, for example,
subscribe shutdown() to the process shutdown event, which fires
while the interpreter and its threads are still fully alive. Calling
it more than once is safe, nothing is uninstalled (bindings stay
applied, so it quiesces rather than tears out the tracing), and a
step that fails is warned about while the rest still run.
flush_sinks() remains as the sink half alone, for putting a trace
on disk mid-run.
Watching calls live: Printer
Printer is the simplest real sink: it prints each event as it
happens, one line when an operation begins, indented by nesting depth,
and a closing line with the outcome and how long it took, using the
same -> and !! markers as tape.tree(). It writes to the stream
you give it, or to sys.stderr by default, flushing every line so the
trace is intact up to the moment of a crash or hang.
>>> import sys
>>> import wrapture
>>> class Gateway:
... def charge(self, amount, currency="USD"):
... return f"ch_{amount}"
>>> charge = wrapture.binding(Gateway, "charge").apply()
>>> printer = wrapture.add_sink(wrapture.Printer(sys.stdout))
>>> Gateway().charge(500)
__main__:Gateway.charge(amount=500, currency='USD')
__main__:Gateway.charge -> 'ch_500' [...]
'ch_500'
>>> wrapture.remove_sink(printer)
>>> _ = charge.remove()
The bracketed figure on the closing line is the elapsed time, in the
same adaptive units tape.tree(times=True) uses (12us, 3.2ms,
1.40s), and for a streamed body (a generator, a request) it also
shows the time spent producing the body and how many items or chunks
there were: [11.8ms, body 4.1ms over 3 chunks]. An operation whose
result was not captured still gets a closing line with the time. Pass
timing=False when stable output matters more than the figure, as in
a doctest or a diff.
Two more options make the printer useful away from a terminal. path
appends to a file instead of a stream (opened on the first line
written, and closed by close(); the file, being a plain path, is
also what a config file can name). timestamps=True prefixes each
opening line with the local wall-clock time to the millisecond, so a
printer file can be lined up with server logs afterwards. Closing
lines are not timestamped; the opening time plus the duration locates
them, and it keeps the tree readable:
14:05:30.117 GET /orders/42?expand=items (myapp.wsgi.application)
14:05:30.118 shop.OrderService.load(order_id=42)
shop.OrderService.load -> <Order 42> [3.2ms]
myapp.wsgi.application -> '200 OK' [11.8ms, body 4.1ms over 3 chunks]
wrapture.add_sink(wrapture.Printer(path="trace.log", timestamps=True))
stream and path are mutually exclusive; with neither, output goes
to sys.stderr. The path is an output path template like the
JSONLines one, so path="logs/trace-{date}.log" with rotate="1d"
gives one printer file per day; see
Output paths and rotation below.
No timeline appears anywhere above: the binding is applied, a sink is listening, and events flow. This is the minimal form of tracing a running application: name the methods that matter in the application’s entry point, register a sink, and a nested trace appears as requests come in, with no timeline and no changes to the code being observed.
Printer is the live view; tape.tree() is the tidy reconstruction
after the fact. In a test, both can run at once, since sinks compose:
keep the timeline for assertions and add a Printer while debugging.
A captured log message (a "log" event, from the [[log]] config
entry or capture_logs()) prints as exactly one line in the tree,
the message repr-escaped so an embedded newline cannot break the
indentation, with the !! marker for a logged exception on the same
line: log myapp.orders ERROR 'charge failed' !! ConnectionError.
Tracebacks never print, here or anywhere in the printer; the
exception object rides the event for the consumers built to hold it.
Counting without retaining
Not every question needs the events themselves; often a number is the answer. Two collectors keep numbers and nothing else, so they are safe to leave running for a whole test suite or a long-lived process:
Counter()counts operations as they begin, failures included. One number, no retention.Aggregate()keeps one row per path: how many operations began, how many completed and how many of those raised, and the total, self, fastest and slowest execution times of the completed ones. Memory is bounded by the number of bound locations, however many events flow.
Both are collectors in the sense of the scheduled tracing page:
placed in a window they accumulate while a run is open and hand back
a report when it closes, which is how “a summary every hour” or “one
report for the whole process” is spelled, in code or in a config file.
Both are also sinks, hearing events while registered, so in code they
can equally be registered with add_sink() and read directly. Both
declare "none" on both capture axes, which matters: when no other
active sink asks for more, recording skips value capture entirely,
including signature binding, the dominant cost of recording a call. A
counter over a hot method costs a fraction of what a recording tape
does.
Counting across a whole suite is how the classic N+1 query regression
gets caught. Bind the database layer’s entry point once, register a
Counter, and give every test a query budget:
# conftest.py
import pytest
import wrapture
from myapp.database import Database
queries = wrapture.Counter()
def pytest_configure(config):
wrapture.binding(Database, "execute").apply()
wrapture.add_sink(queries)
@pytest.fixture(autouse=True)
def query_budget():
before = queries.count
yield
used = queries.count - before
assert used <= 25, f"test issued {used} queries; N+1 regression?"
A test that quietly starts issuing a query per row now fails with a number attached, and because the counter asks for no values, the whole suite pays almost nothing for the guarantee.
Aggregate answers the follow-up question, “where is the time going”,
without retaining a single event. Its stats are there to read at
any time, and its report renders them as a table:
with wrapture.window(collect=[wrapture.Aggregate()]) as run:
drive_traffic()
print(run.reports[0].text)
aggregate "aggregate" run 1, 2026-08-18 14:05:30 to 14:06:00 +10:00 (30.0s), pid 4142
3 paths, 153 operations begun, 153 completed, 3 raised
calls total self per-call min max errors path
100 114.0ms 114.0ms 1.1ms 1.0ms 1.9ms shop:Gateway.charge
50 118.2ms 4.2ms 2.4ms 2.1ms 3.4ms shop:Processor.process
3 24us 24us 8us 2us 14us 3 shop:Gateway.refund
self is the figure profilers rank by, and the table is sorted by
it: the time spent in the operation itself, excluding the time its
observed children account for. A method that is slow because of what
it calls ranks low on self time; a method that is slow in its own
right ranks high, and that distinction is what tells you where to
look. No external profiler can compute it for an arbitrary handful of
bindings, because it only sees whole call stacks; wrapture computes it
from the parent links as events close, retaining nothing.
Every recorded event carries started and duration (exceptions
included), which is what the rows are built from. For generators the
accumulated body time stands in for the wall duration, since the wall
figure includes the consumer’s time between yields, which is not the
generator’s to spend. The same numbers are available on a tape:
tape.self_time(event) for one event, and tape.tree(times=True)
for the whole picture, covered on the
unit testing page.
Composing sinks: fan-out, sampling and filtering
Combinators make sinks compose like building blocks. Each is itself a sink wrapping others, so they nest freely:
Fanout(*sinks)delivers every notification to several sinks, with the same isolation the registry gives top-level sinks: one broken inner sink is counted and skipped, its siblings still hear the event.Filter(predicate, sink)forwards only events the predicate accepts. The predicate is consulted once, when the event enters, and the decision sticks, so the inner sink always sees properly paired enter and exit notifications even for fields that change as the operation completes.Depth(max_depth, sink)forwards only the top levels of the call tree:Depth(1, ...)is roots only,Depth(2, ...)roots and their direct children.Sample(rate, sink)keeps a random fraction of whole call trees. The decision is made once per tree, at its root, and inherited by everything beneath, because sampling per event would emit children whose parents were never seen.
One registration can therefore serve several needs at different costs:
wrapture.add_sink(wrapture.Fanout(
wrapture.Aggregate(), # always on, numbers only
wrapture.Depth(2, wrapture.Printer()), # live view, top of tree
wrapture.Sample(0.01, detailed_sink), # 1% of trees, in full
))
A combinator declares the capture levels of what it wraps (Fanout
takes the highest of its inner sinks’), read at construction, so
capture negotiation sees through the composition and the aggregate
above never forces values to be captured for the printer’s sake.
Deciding at the binding: when=
Sink-side narrowing with Filter or Sample happens after an event
has been constructed and its values captured; the recording cost is
already paid by the time the sink declines it. When the point is to
keep a hot binding cheap, decide before any of that exists:
charge = wrapture.binding(
PaymentGateway, "charge",
when=lambda instance, args, kwargs: kwargs.get("tenant") == "acme",
)
The predicate is consulted once per operation, only while something is
listening, and a falsey answer means no event is constructed at all:
no signature binding, no capture, no delivery. The operation itself
still runs and behaviour still applies, and the skip is counted on the
binding’s filtered_calls, so a shorter trace than expected can be
explained rather than guessed at. On an attribute binding a set passes
the written value as the one positional argument and a get or delete
passes empty args, the same mapping onto call shape the behaviour
pipeline uses. For a generator target the decision is made once, at
the call, and covers the whole iteration.
Because the answer comes from a callable, adjusting the filter at runtime needs no API: write the predicate to consult state you control, and change that state while the process runs.
TRACED_TENANTS: set[str] = set()
def traced_tenant(instance, args, kwargs):
return kwargs.get("tenant") in TRACED_TENANTS
wrapture.binding(PaymentGateway, "charge", when=traced_tenant).apply()
# later, in a live process: start tracing one tenant, then stop
TRACED_TENANTS.add("acme")
TRACED_TENANTS.discard("acme")
Two cautions. The predicate runs in the application’s call path on every operation while recording is active, so keep it fast. And it is ordinary user code: if it raises, the caller sees the exception, the same contract as a custom capture policy.
Per-operation sampling fits here too
(when=lambda instance, args, kwargs: random.random() < 0.01), but
where nesting matters prefer Sample, which decides at the root and
keeps whole trees together.
Declining a whole tree: tree=
A when= decline skips one event, the declined operation’s own.
Whatever records inside the operation still records, and with no
event above it each such inner event becomes a root of its own. For
the tenant predicate above that means the other tenants’ gateway
calls, if the gateway is bound too, appear on the tape as anonymous
trees with no charge to explain them. Sometimes that is exactly
right: a behaviour-only binding on an interception point
(when=False) must stay out of the trace while what runs beneath it
records. When the intent is “nothing from here down”, say so:
charge = wrapture.binding(
PaymentGateway, "charge",
when=lambda instance, args, kwargs: kwargs.get("tenant") == "acme",
tree=True,
)
With tree=True a decline suppresses recording for the operation’s
whole dynamic extent: every binding that fires beneath it, attribute
accesses, blocks and log captures included, a generator’s iteration
and a coroutine’s await, and a thread handed the context with
propagate() or detach(). Behaviour still applies throughout; only
recording stops. The inner bindings count each skipped operation on
their own filtered_calls, so a shorter tape than expected can
still be explained binding by binding. when=False, tree=True
silences a subtree unconditionally, for a noisy library call whose
internals are never worth seeing. tree=True needs a when= to act
on and is refused without one; on a value or mapping binding, which
records nothing, it is refused like the other recording options.
The flag is the same on observed(), bound() and discover(), and
on the request modes, where it is what an ignored request wants; the
WSGI page covers
filter_requests(), the declarative request predicate that pairs
with it.
Terminal nodes: leaf= and category=
tree=True drops an operation and everything beneath it. The
related shape is to keep the operation’s own event and drop
everything beneath it: a client library’s entry point is worth
seeing, the HTTP requests it makes internally are not, and the entry
point’s duration should cover them. That binding is a terminal node
of the tree, and leaf=True says so:
wrapture.binding("billing.client:Client.charge", leaf=True, category="external")
A leaf records as usual, and for its whole extent nothing that would
make a span records beneath it: nested bindings, attribute accesses,
blocks, a nested request, through a generator’s iteration, a
coroutine’s await and a streamed response body, and into threads
handed the context with propagate() or detach(). Behaviour still
applies beneath it; only recording stops. Log captures are the
exception: a log record is an instantaneous event on whatever is in
flight, so beneath a leaf it records attached to the leaf, which is
the detail a terminal node can still usefully carry. Two properties
follow from nothing beneath the leaf being pushed: annotate() from
the leaf’s own code lands on the leaf, since it is the innermost
event in flight (from inside a silenced call beneath it, one that
would have been a span of its own, an unaimed annotate() lands
nowhere rather than on the leaf, so a silenced inner layer cannot
rewrite the leaf’s story, while current_event() with its filters
still reaches the leaf), and trace_headers() called from the
leaf’s own code, or from a silenced call beneath it, still yields
the tree’s identity, so a probe of your own inside the leaf can
propagate it deliberately. Packaged client instrumentation
follows a stricter contract, though: propagation belongs to the
level that records, so an instrumented HTTP client silenced beneath
your leaf injects nothing, and a leaf that does not propagate at its
own level sends no trace identity downstream (deliberately: the
service beneath an opaque leaf is often one that would not
understand the headers). The
instrumentation packages page states
the contract.
Unlike a tree=True decline, a leaf counts nothing on the bindings
beneath it. The silence is structural, declared with the binding,
and visible on the tape by construction: the leaf event is there and
has no children, which is the whole explanation. A leaf beneath a
tree=True decline stays fully silent, since the decline’s stronger
silence is never lowered, and a leaf beneath a leaf is silenced like
anything else. The flag composes with when= and tree= on the
same binding, and is the same on observed(), bound(),
discover(), block() and the request middlewares; a value or
mapping binding refuses it with the other recording options.
category= is the other half of declaring what a target is: the
kind of operation its events are, one of "external" (a call to
another service), "database", "datastore", "messaging",
"task", "server" (the handling of an inbound request at a
boundary the request middlewares do not speak for, the receiving
mirror of "external"), "consumer" (the receiving mirror of
"messaging" and "task": handling one message, running one queued
job) or "template", aligned with the categories instrumentation
packages are organised by. The category declares the operation’s
role; where its trace identity comes from is a separate declaration
(joins= or links= on a block), and neither implies the other. It
is carried on every event the binding records as a field of its own,
event.category, never as a data key, and never changed afterwards:
it is decided before the event exists, at the declaration or by a
resolver given in its place (see below),
never during the call, so annotate() cannot set it. Leaf and
category are usually declared together on an outbound client, but
neither implies the other, for the same reason when=’s reach never
depends on what is passed to it.
The category is for programs, not for the printed tree: Printer,
canonical() and mermaid() do not show it, since the name already
says what urllib.open is. It is selected on everywhere events are
selected. On a tape, of_category("external") narrows any event log
as of_kind() does, tape.where(category="database") selects
across bindings, and current_event(category="external") aims
annotate() or note_exception() at the nearest enclosing external
call without knowing its binding, the way kind="request" aims at
the request. At a sink, a Filter predicate reads
event.category, and a config’s filter table takes category
beside kind, path and label. In the JSON Lines record it is a
category key, present only when set. And the OpenTelemetry export
uses it for the span kind and, through the category’s data-key
contract, for the semantic-convention attributes; the
OTel page has the
contracts.
Streaming to disk: JSONLines
JSONLines(path) writes each completed event to a file as one JSON
object per line, the JSON Lines format that
jq, pandas and log tooling consume directly. A line is written when
an event closes, exit and error alike, so every line carries the
outcome and the timing; lines therefore appear in completion order,
children before the operation that contains them, and sorting by
seq with nesting rebuilt from parent_id recovers the tree.
The form is deliberately stable. Every line has seq, parent_id
(null for a root), depth, kind, path, and the thread_id and
thread_name of where the operation began; everything else appears
only when it was observed: label, started and duration
(plus body_duration and items for generators), arguments or the
raw args/kwargs shape, forwarded, result, exception (type
and message), caught (a list of the exceptions noted with
note_exception(), each with type, message and offset, the
seconds since the event started), value and previous for
attribute writes, injected, stack, and data. Absence means “not captured”, so an absent
result stays distinguishable from "result": null, a call that
returned None, exactly the distinction MISSING preserves in memory.
Two properties make it safe to leave running:
The application is never blocked on I/O. Lines go onto a bounded queue drained by a background writer thread; when the queue is full the line is dropped and counted on
droppedrather than making the observed call wait.flush()blocks briefly until queued lines are on disk (shutdown()andflush_sinks()call it for you);close()flushes, stops the writer, and closes the file.It declares
"summary"capture, so it neither retains live objects nor fails on unserialisable ones, and values that reach it captured by reference anyway (a binding’s override, a tape’s requirement) are reduced to bounded summaries at serialisation time, with a depth limit that cuts even self-referential structures.
There are no built-in size caps; growth is managed by putting a time variable in the path and rotating on an interval, described next.
Output paths and rotation
Every path wrapture writes to, a JSONLines trace or a Printer file, is a template, expanded when the file is opened and never per line. A path with no variables is a template that expands to itself. The variables:
variable |
value |
|---|---|
|
process id, for pre-fork workers writing side by side |
|
hostname, for fleets writing to shared storage |
|
the sink’s |
|
|
|
|
|
|
|
|
|
any |
|
any |
A misspelt variable is an error where the path is given, not an hour
into a run when the file first rotates. Values are sanitised so an
expansion can never introduce a path separator or climb out of the
directory the template’s static part names, and the parent
directories of the expanded path are always created, so
traces/{date}/trace-{time}.jsonl simply works. sink.path says
which file the sink is writing right now, and the sink’s repr()
(which config.report() includes) shows both the template and the
current file.
Rotation is wrapture’s own. reopen() closes the current file,
expands the template again and opens whatever that names, so with a
time variable in the path it moves on to a new file, on every
platform, with no external mover; queued JSONLines lines drain to the
old file first. rotate= calls it on an interval ("15m", "1h",
"1d", "1h30m", or a number of seconds), and align=True puts
that on the wall-clock boundary in local time, so hourly means on the
hour and daily at midnight, computed afresh each time so daylight
saving is followed rather than drifted through. A path with no time
variable and a rotate= reopens the same file each time, which is
pointless, and is warned about when the sink is built. For rotation
on demand, call reopen() from a signal handler. exceptions= says
how much of an exception the file carries, for a file that will
leave the machine: exception messages routinely embed values (the
key a KeyError names, the statement a database driver echoes). The
levels are "full" (the default), "message" and "type", each
sink that exports exceptions interpreting them against what it
would otherwise write; JSONLines never writes a stacktrace, so
"full" and "message" both give the type and message, and
"type" keeps the type name alone.
wrapture.add_sink(
wrapture.JSONLines("traces/{date}/trace-{time}-{pid}.jsonl", rotate="1h", align=True)
)
The interval timer is a single daemon thread per process, started by
the first sink that needs it, so a sink with no rotate= costs no
thread.
This completes the inherited-dev-server story from the top of this page. The whole intervention is a few lines in the application’s entry point, with no timeline and no changes to the code being observed:
# the tail of manage.py, or the app factory
import wrapture
from myapp.orders import OrderService
from myapp.payments import PaymentGateway
wrapture.binding(OrderService, "place_order").apply()
wrapture.binding(OrderService, "restock").apply()
wrapture.binding(PaymentGateway, "charge").apply()
wrapture.add_sink(wrapture.Fanout(
wrapture.Depth(2, wrapture.Printer()),
wrapture.JSONLines("trace.jsonl"),
))
Requests then print a live two-level tree to the console while the
full nested trace streams to trace.jsonl, worker threads included,
ready for jq:
$ jq -c 'select(.path | endswith("PaymentGateway.charge"))' trace.jsonl
$ jq -c 'select(.exception)' trace.jsonl
Writing your own sink
The protocol is small enough that special-purpose sinks are cheap to write. A sink that remembers which operations ran longer than a threshold, keeping numbers rather than events:
class SlowCalls(wrapture.Sink):
"""Remember the operations that ran longer than a threshold."""
capture_args = "none"
capture_result = "none"
def __init__(self, threshold):
self.threshold = threshold
self.slow = []
def on_exit(self, event):
if event.duration is not None and event.duration > self.threshold:
self.slow.append((event.path, event.duration))
Declaring "none" on both axes keeps it near-free, exactly as with
the counting sinks above; drop the declarations if the sink needs to
look at argument or result values.
One caveat for sinks that hold state, like the dictionary above: notifications arrive from whatever thread ran the observed operation, so a sink shared across threads must protect its own state if it mutates anything more compound than the example’s per-key counter.
Instrumenting your own code
wrapture’s config route exists for code you do not own: the wrapping is declared outside, the observed code never imports wrapture, and nothing needs to be redeployed differently. For your own code the calculus flips. An author happy to mark up their source finds a separate config file friction, because the declaration lives apart from the code it describes; for them wrapture has an embedded surface, four pieces that read as one:
@wrapture.observedis the def-site decorator: the function’s calls record as"call"events wherever it ends up, near-free when nothing listens. Bare on a def, or@wrapture.observed(label="charge", capture="summary")with options; everything but the callable is keyword-only. The unit testing guide covers the proxy’s full character, including the callables no binding could reach anyway (closures, partials, callbacks).wrapture.block(name, **data)marks what is smaller than a function: a with statement declaring the enclosed stretch as one"block"event, children nesting under it, the body’s wall time as its duration, an escaping exception recorded and still propagated. Like a log statement, it is inert when nothing listens, so the marker stays in production code permanently.when=(a plain bool, computed before entry),tree=(a declined entry silences the whole body) andstack=mirror theirbinding()counterparts, so a hand-written boundary filters and explains itself like a bound one.wrapture.annotate(**data)attaches what the code knows and no generic capture can infer (a row count, a cache hit) to the in-flight event, and is unconditionally safe to call. The number an aggregating sink wants is an annotation on an event: count inside the loop, annotate the total onto the enclosing block on the way out, one event rather than ten thousand.wrapture.note_exception(exc)is annotation’s sibling for a failure the code handled itself: an exception caught and dealt with, so the scope completes normally, is noted against the event as acaughtentry, marking the operation failed for every sink and filter without touching control flow. Equally safe to call unconditionally;current_event()with its filters aims either verb past the in-flight event at the unit of work that failed.WSGIMiddlewareandASGIMiddlewarewrap the application object at the edge in code,application = wrapture.WSGIMiddleware(application), grouping events per request exactly as the config-declaredmode = "wsgi"binding does, since both are the same class.
Sinks complete the all-in-code deployment: a CLI tool or worker that
configures its observability from command line options calls
add_sink() programmatically and never touches a config file.
Tracing needs nothing at all, since it is on by default: a root
block or observed call mints a trace identity, and trace_headers()
carries it outward. Deliberately, no code surface can force a trace
on: the [trace] table’s enabled = false is an operator’s
decision, and the per-entry trace = true exception lives in the
same file, written by the same hand. Code that wants its blocks
traced simply leaves tracing enabled.
The two postures are a choice, not a default and a workaround, and they mix: a service can decorate its own critical paths and still be swept by an operator’s config, the events landing on the same tape and the same sinks either way.
Configuring from a file
The dev-server example above still edits the application’s entry
point. A config file moves the same setup out of the code entirely:
observe rules, the sink, capture and sampling, written as TOML and
applied as one unit. This is the format the python -m wrapture
runner below consumes, the autowrapt injection path consumes, and
code can apply directly:
import wrapture
source = wrapture.find_config()
if source is not None:
wrapture.load_config(source).apply()
find_config() implements the source precedence: a path in the
WRAPTURE_CONFIG environment variable, then wrapture.toml in the
current directory, then a [tool.wrapture] table in
pyproject.toml. The first source found wins outright; sources are
never merged. Applying in code like this is a deployment posture of
its own, with ordering and shutdown considerations under hosts such
as mod_wsgi and gunicorn; Manual setup covers it
in full.
A complete file:
pythonpath = "tracing"
capture = "summary"
[[observe]]
target = "myapp.orders:OrderService"
match = "*"
exclude = "_*"
[[observe]]
target = "myapp.payments:PaymentGateway"
name = ["charge", "refund"]
[[observe]]
target = "myapp.parsers"
match = "parse_*"
[[sink]]
type = "jsonlines"
path = "trace.jsonl"
[[instrument]]
name = "wrapture_local.hooks:OrdersInstrumentation"
tenants = ["acme", "globex"]
Choosing what to observe
Each [[observe]] entry binds members of one exact target. target
is never a pattern: it is "module" or "module:path", the same
colon convention event paths use, so a bound member’s event path is
literally the target plus the member’s name. Members within the
target come from name or match, one of which is required; each
accepts a single string or a list:
namelists exact members. Every listed name must exist or the config fails to apply, and it binds anything, properties and other attributes included.matchis anfnmatchcasepattern over the target’s own immediate members only: no traversal into nested classes or submodules, and no inherited members. It selects routines only (functions, static and class methods; for a module target, only functions the module itself defines), skipping properties, nested classes, plain data and anything already wrapped;nameis the escape hatch that binds the skipped kinds explicitly.excludesubtracts patterns from the match, and a match that selects nothing warns withConfigWarning.modeis normally omitted, leaving each binding to detect its own. The accepted values are"wsgi"and"asgi", which wrap the named members as applications of that protocol in the recording middleware; each requiresname, since a pattern must never bulk-install middleware, and for a wsgi or asgi entryredactnames query string parameters. The WSGI and ASGI request tracing pages cover them.leaf = truemakes the entry’s bindings leaves, andcategorynames what kind of operation their events are ("external","database","datastore","messaging","task","server","consumer"or"template"): thebinding(leaf=, category=)options as TOML, see Terminal nodes.datais a table of static tags every event from the entry’s bindings starts with: string keys to scalars or flat lists of scalars, the shapebinding(data=)takes in code. It is the only way to annotate events from code the config observes but does not own, and the tags show wherever the event does, aswrapture.data.*attributes on an exported span for instance. Anannotate()inside the observed code overrides a seeded key; on a wsgi or asgi entry the request’s own fields (method, path and the rest of the request data-key contract) are written after the seed and cannot be overridden by it.
The blast radius of a pattern is thereby one level of one named container, stated on the line above it.
The same selection is available in code as discover(), which takes
the target and patterns an observe entry would and returns a binding
group; the two share one implementation, so a pattern selects the
same members however it is spelt. Unlike an observe entry it resolves
immediately and an empty selection raises; the
unit testing page covers it.
Observe entries defer. Applying a config never imports a target
module: it registers a post-import hook per target, which fires
immediately when the module is already imported and otherwise when
the application imports it, at which point the entry’s members are
resolved and its bindings applied. The application’s import order is
never changed by observing it. This holds under Python 3.15’s lazy
imports too: a lazy import statement imports nothing, and first
use reifies the module through the normal import machinery, so the
bindings land at that moment, before the touched attribute is even
fetched. Deferral has one honest cost: a
misspelled target module is indistinguishable from one not imported
yet, so it is not an apply-time error; the applied record’s
pending view names the entries still waiting, and a target whose
module is never imported over the life of the process is reported in
a ConfigWarning at interpreter shutdown, so an empty trace has an
explanation.
The top-level capture key overrides the capture level on every
binding the file creates. An entry can set its own: capture on an
entry covers both axes and beats the top-level key, while
capture_args and capture_result each override one axis, the
binding() options of the same names spelt as TOML. The levels are
the ones the capture policies
define, so capture_result = "none" keeps a call’s arguments and
drops what it returned, and capture_args = "types" records only
the type of each argument. On a wsgi or asgi entry the two axes are
the request and the response.
redact on an entry names parameters whose values are replaced with
<redacted> on that entry’s bindings, everything else capturing at
the level the entry’s arguments axis resolves to. Results have no
parameter name, so a secret that comes back out is hidden with
redact_result = true instead, which records the marker in place of
the result (where capture_result = "none" would record nothing at
all); it cannot be combined with capture_result, which would have
nothing left to apply to. redact_marker replaces the <redacted>
text for both, and needs one of them. This is the expected posture
for anything leaving the process: streaming sinks already reduce
values to bounded summaries, but a summary of a secret is still a
secret, so tokens, card numbers and credentials should be redacted by
name at the entry, where they never reach any sink at all.
stack on an entry records how control reached each of its events,
the binding(stack=) option as TOML: "caller" for the calling
frame alone, a positive frame count, or "full", priced as the
stack capture
section describes. It answers “which code path is hitting this” for
code the config observes but does not own.
Sinks: the [[sink]] list
[[sink]] is a list of destinations; several entries fan out
implicitly, so the list is the fanout and a single entry is a list
of one. Each entry names its sink with type and passes every other
key to the constructor. Two builtin names cover the event streams,
printer and jsonlines; anything else is reached by module:attr
reference: a callable, invoked with the remaining keys as keyword
arguments, that must return a Sink. So everything to a daily
rotating trace plus a shallow live view is two entries:
[[sink]]
type = "jsonlines"
path = "traces/trace-{date}-{pid}.jsonl"
rotate = "1d"
align = true
[[sink]]
type = "printer"
depth = 2
The depth key above is one of three gating keys an entry can
carry, each the file’s spelling of a combinator wrapped around that
sink alone: sample = 0.1 keeps that fraction of whole call trees
(Sample), depth = 2 forwards only the top levels of each tree
(Depth), and filter forwards only matching events (Filter).
filter is either a table of event fields to patterns, kind,
path, label and category, each a string or list of strings
matched with fnmatch rules and all of which must match, or a
module:attr
reference to a predicate taking the event. Several gating keys on
one entry wrap in a fixed order however they are written, sample
outermost, then depth, then filter innermost:
[[sink]]
type = "printer"
sample = 0.5
depth = 1
filter = { kind = "request" }
[[sink]]
type = "jsonlines"
path = "traces/orders-{date}.jsonl"
filter = { path = "myapp.orders:*" }
There is no top-level sampling key: sampling belongs to the sink it
gates, and a whole-config rate is one sample on each entry.
Sinks in [[sink]] listen for the life of the process. For sinks
that should listen only for a while, on a schedule or on demand, and
for collectors that produce periodic reports, see
Windows, whose [[window.collect]] entries use this
same grammar.
One nesting case exists, for a factory sink that is a container in
its own right, routing or fanning out internally: it takes the
sinks it routes to under to, written as [[sink.to]] entries and
passed to the factory as a to= list of built sinks. Inner entries
use exactly the same grammar, gating keys included, so nothing
deepens further; anything more elaborate belongs in Python:
[[sink]]
type = "wrapture_local.sinks:Router"
[[sink.to]]
type = "jsonlines"
path = "traces/requests-{date}.jsonl"
filter = { kind = "request" }
[[sink.to]]
type = "printer"
depth = 1
# wrapture_local/sinks.py
import wrapture
class Router(wrapture.Sink):
def __init__(self, to):
self.to = to
...
Sink references resolve when the file loads, because the sink must
exist before events can flow. A relative path on a builtin sink is
anchored to the config file’s own directory, as pythonpath entries
are, so the file says where its output goes whatever the process’s
working directory; a factory receives its keys as written.
Capturing log messages: the [[log]] list
Each [[log]] entry is capture_logs() spelt as TOML: the log
messages the application emits through the standard library logging
module become events of kind "log", flowing to the config’s sinks
beside the calls, nested under whatever observed operation emitted
them. The keys are the function’s arguments, all optional:
[[log]]
name = "myapp.*"
level = "INFO"
exclude = "myapp.health*"
exclude_message = "*password*"
name and exclude are fnmatch patterns (or lists) over logger
names, and level is a threshold, name or number, meaning “at least
this severe”; the default WARNING keeps volume deliberate. Repeat the
entry for different patterns at different levels. Unlike an observe
entry there is no target to wait for, so captures apply immediately
when the config does, and report() lists them.
Capture hears each record once, on the logger that emitted it,
before propagation and regardless of the application’s handler
configuration, which it never touches; records suppressed by a
logger’s own level were never emitted and are not captured.
exclude_message is the safety valve, dropping matching messages at
capture so they reach no sink and no file, for content that must
never be recorded anywhere. Selection any finer belongs to the
sinks: a filter predicate on one [[sink]] sees the whole event,
data["message"] included, so routing (say, structured messages to
one file) is a predicate function away without blinding the other
sinks.
The recorded fields ride in event.data (level, levelno,
message, module, funcName, lineno), and a record logged with
exc_info carries its exception on the event’s exception field,
never in the message: the message-plus-traceback blob is a Formatter
artifact of the logging module’s own output path, and wrapture
captures the record, not the output. One retention note for
long-lived processes: a live exception keeps its traceback frames
for the event’s lifetime, so logging.exception() in a hot loop is
a heavier capture than the message alone.
Instrumentation: code that patches a target
Declarative [[observe]] entries cover plain observation. Everything
richer (behaviour pipelines, redaction policies, when= predicates,
iterator proxies, a framework’s choke points) lives in ordinary
Python, and the config’s way of running such code at the right moment
is an instrumentation: a subclass of wrapture.Instrumentation
that declares, as class data, the one target package it covers, and
as decorated hook methods the trigger modules under it, each hook
called once when its module is imported, or immediately if it
already was. An [[instrument]] entry names the class, and its
remaining keys are the class’s declared settings:
[[instrument]]
name = "wrapture_local.hooks:OrdersInstrumentation"
tenants = ["acme", "globex"]
# wrapture_local/hooks.py
import wrapture
class OrdersInstrumentation(wrapture.Instrumentation):
"""Restock tracing for the tenants named in the config."""
target = "myapp"
removable = True
settings = {
"tenants": wrapture.Setting((), "tenants whose restocks are traced"),
}
@wrapture.instrumentation_hook("myapp.orders")
def orders(self, name, module):
tenants = self.settings["tenants"]
def traced_tenant(instance, args, kwargs):
return kwargs.get("tenant") in tenants
restock = wrapture.binding(module.OrderService, "restock",
when=traced_tenant)
restock.apply()
self.on_cleanup(restock.remove)
The pieces, in the order they matter:
targetis the import path of the module tree the class covers, a top-level name or a dotted path (http.client), and every trigger module a hook declares must live at or under it; a class claiming a module outside its target is refused the moment it is defined. The target is what the conflict check works on: two enabled entries whose targets overlap, the same path or one inside the other, are aConfigErrorwhen the config is built, and since every trigger lives under its target, that is also what stops one module being patched twice.settingsdeclares every key the entry may carry, each aSetting(default, description). Declaration is what makes the file validated rather than passed through blind: an unknown key is aConfigErrorat load (thetenatstypo fails there, not inside the hook), and so is a value whose outer type does not match the default’s (a string where the default is an integer, a scalar where it is a list). What is inside a list or table is the class’s own business, checked inconfigure()if it needs checking, which runs once after construction and before any trigger fires, so aConfigErrorraised there still surfaces at apply time. The resolved values, class defaults under the entry’s, areself.settingson the instance. A class that binds several distinct groups of call sites declares each as anAspect, a sub-table of the entry ([instrument.views]) carrying that group’s switch, the recording keys an[[observe]]entry takes (capture,capture_args,capture_result,redact,redact_result,redact_marker,leaf,stack) and any settings of its own; a bare boolean under the aspect’s name is itsenabled, and the keys of the aspect the class marks primary may be written flat on the entry. Aspects has the whole scheme.The decorated hook is the door: one
@wrapture.instrumentation_hook(module)method per trigger, called asmethod(self, name, module), the method name free and the trigger string appearing only on the decorator (stack the decorator for one method serving several triggers, withnamesaying which fired). Inside it, build the patches against the module handed in and register the undo withself.on_cleanup(callback), tagged automatically with the trigger being applied. Bindings are the common case, and the recipe is three lines: build awrapture.bindings(...)group,group.apply(),self.on_cleanup(group.remove). For teardown that does not decompose into callbacks, pair a cleanup method with the hook via@<hook>.cleanup. The module defining the class must not import the target, so that loading the class never imports the package it is about to patch.removable = Trueis the class’s claim that it can undo itself. It defaults to false, so a package has to say so; the claim governsreport()and the warningrevert()gives, and the cleanup callbacks run either way. A hook may override the claim for its own trigger withremovable=on the decorator.name,descriptionandversiondefault from the entry point and distribution for a packaged instrumentation, and to the target, the class docstring’s first line and nothing for a local one.
The name key takes three spellings. A module:attr reference, as
above, names a class by its location: operator code next to the
config file, made importable by pythonpath, or a package under
test. A bare name such as flask names an instrumentation an
installed package registered in the wrapture.instrumentation entry
point group (one entry per class, the entry point name being the
instrumentation’s name, so one distribution can ship instrumentation
for many packages); it resolves when exactly one installed
distribution registers it. And name@distribution, as in
requests@wrapture-instrumentation-acme, picks between two
distributions that register the same name, the part after @ being
the distribution name as pip list shows it, matched after the usual
name normalisation; a bare name two distributions register is a
ConfigError listing the qualified spellings to choose from. enabled = false keeps an entry in the file but inert: still validated, but
applying nothing, taking part in no conflict check and satisfying no
requirement, which is what lets a generated file be shipped with
every entry present and switched off. An entry may carry triggers,
a subset of the class’s declared trigger modules to register, the
escape hatch for excluding one hook of a large instrumentation while
a fix is pending; a name the class does not declare is a
ConfigError. A class may also declare requires, other targets
that must have an enabled instrumentation in the same config, a
single name or a sequence of them; requirements are never pulled in
automatically, and a missing one is a ConfigError naming the
target.
Applying is as deferred as observing: each trigger module gets one
post-import hook, which fires immediately for a module already
imported and otherwise when the application imports it, and a hook
that raises from inside an application import warns and lets the
import continue, exactly as a failing observe entry does. The
installed version of the target, read from package metadata, is what
a class’s supports range (a PEP 440 specifier) and any per-trigger
range on a hook’s decorator
(@wrapture.instrumentation_hook("flask.sansio.app", supports=">=2.3"))
are checked against before anything fires: a version outside the range registers nothing for the
affected triggers and warns with ConfigWarning, never an error,
because an environment newer or older than the package is not a
misconfiguration. AppliedConfig.instrumentations is the live record
of each applied instance, with its settings in effect, the target
version found, and applied and pending trigger lists; report()
shows the same; and revert() removes each in reverse application
order through remove(), neutralising the hooks that never fired,
so instrumentation is no longer outside what a config can undo.
What is installed, and what a file could switch on, is a command
away: python -m wrapture.tools instrumentation lists every
registered instrumentation from class data alone, applying nothing
(distribution and version, target and installed version with the
support verdict, triggers, requirements, settings with defaults,
removability); --config PATH adds the file’s own reference-form
classes and marks the entries it selects; and --toml writes
[[instrument]] entries to standard output instead, one per installed
instrumentation, every one enabled = false and every setting a
commented-out line at its default with its description beside it, so
un-commenting a line is the whole act of configuring. Instrumentation
packages covers the
packaging side: the entry point, the import-light rule, the naming
convention, and testing the class directly.
Framework instrumentation has one more call to know about. A
framework routinely catches the exception a view raised and hands it
to an error handler that returns normally (Flask.handle_exception,
Django’s handle_uncaught_exception, Celery’s on_failure), so the
request or task that failed completes with a result and no
exception; the handler is the only code that can be bound, and
noting through a current_event() handle from a behaviour on it is
how the failure reaches the event that actually failed. The aiming
rule: wrapture.current_event(kind="request").note_exception(exc)
when the unit of work is a request wrapture’s own middleware
recorded, which no binding of yours created, and
current_event(binding=self.task_call).note_exception(exc) when it
is an event of the instrumentation’s own binding (a Celery
instrumentation’s Task.__call__, say), the handle it already
holds. Both select the nearest enclosing match; when nothing
matches the handle is empty and the note quietly does nothing, so
the call needs no guard. The bare wrapture.note_exception(exc)
and wrapture.annotate(**data) are shortcuts for the same verbs on
current_event() with no filters, one mental model throughout. A
handler that fires after the unit of work has closed cannot note
against it: the sinks have already heard that event close, so the
note is refused with a ConfigWarning naming the event and the
exception, which tells you the failure needs recording another way.
The unit testing guide
has the full semantics.
Code next to the config file
Uninstalled operator code, the factories and callbacks above, is made
importable by the top-level pythonpath key: a directory or list of
directories prepended to sys.path when the file loads, with
relative entries anchored to the config file’s own directory, so a
config plus its companion code stays a self-contained bundle wherever
the process launches from. Prepending can shadow installed modules,
so keep such code in a distinctively named package (wrapture_local/
above), never in loose generically named files.
Trust and failure: what a bad config does
A config file can name arbitrary code to run, so loading one is
equivalent to executing code; the trust boundary is write access to
the file, as it already is for anything else the process imports.
Failures are loud: unknown keys, name and match together, a
reference that does not resolve, a factory that returns something
other than a Sink, all raise ConfigError, and a config that
fails partway through applying unwinds whatever it had installed
before the error propagates. A Tape is refused as the sink,
however deeply a factory’s composition buries one: a tape retains
every event and a config sink lives for the life of the process, so
unbounded retention stays a deliberate code-level choice.
Validation that needs the target module (a name that must exist, a
match with nothing to select) runs when the entry’s hook fires,
and the posture depends on when that is. Fired during apply itself
(the module was already imported), the failure is the caller’s to
hear and raises. Fired later, from inside the application’s own
import of the module, it warns with ConfigWarning and drops the
entry instead, because observation must never fail the import it
rode in on; an instrumentation hook that raises after apply is
handled the same way.
The same setup is available programmatically as the Config class
with ObserveEntry and InstrumentEntry values, which is exactly
what the loader builds: the file can say nothing that Config
cannot, and code can additionally pass live objects, a constructed
sink, a callable capture policy or an Instrumentation subclass
itself, where the file is limited to what TOML can spell. apply()
returns the live AppliedConfig record: bindings grows as hooks
fire, pending names the entries still waiting, instrumentations
holds each applied instrumentation, report() renders the whole
picture as text, which is the way to ask an injected process what is
actually installed, and revert() takes everything down again,
neutralising hooks that have not fired.
Zero-code runs: python -m wrapture
The runner applies a config and then runs your program, with nothing in the program saying so:
$ python -m wrapture -m myapp
$ python -m wrapture manage.py runserver
$ python -m wrapture --config trace.toml -m myapp
Without --config the precedence chain above locates the file, and
finding none is an error rather than a silent untraced run. The
target runs as __main__ with sys.argv rebuilt to the target and
its own arguments, exactly as python -m myapp or
python manage.py runserver would have run it, the same -m
convention as pdb, cProfile and coverage. Everything after the
target belongs to the target, however option-like it looks, so
wrapture’s options go before it.
The ordering is the point: the config is applied before the target
runs, so patches are in place before the target module imports
anything, and a from applib import parse in the application still
picks up the observed function. This is the zero-code form of the
dev-server scenario at the top of this page: the few lines in the
entry point become a wrapture.toml next to the project, and
python -m wrapture manage.py runserver traces the inherited
application untouched.
Runnable demonstrations of this whole workflow live in the
examples directory
of the repository: a live printer over an order flow, a threaded
pipeline streamed to disk and rendered in Perfetto, and an
operator-code bundle showing pythonpath and a local instrumentation
class with a setting read from the file. Each is a directory to cd
into and run.
Injection without a launcher: autowrapt
The runner still owns the command line. When even that is unavailable, because something else launches the process (a service manager, a container entry point, a WSGI server), the same config can be injected at interpreter startup through autowrapt. Two opt-ins gate it, both outside wrapture:
$ pip install autowrapt
$ AUTOWRAPT_BOOTSTRAP=wrapture python myapp.py
Installing autowrapt is what makes interpreter startup do anything at all, and the environment variable names an entry point group that autowrapt hands to wrapt’s post-import hook discovery once site initialisation completes; wrapture’s entry in that group hooks a module that is always already imported, so its bootstrap fires immediately, at startup. Absent either opt-in, the entry point in wrapture’s package metadata is inert: wrapture has no dependency on autowrapt and never imports it. When it fires, the bootstrap resolves the same config precedence chain the runner uses and applies what it finds, before the application’s own code runs. The runner and autowrapt are two doorways into identical machinery; nothing is expressible through one and not the other.
How autowrapt hooks startup, a PEP 829 .start file on Python 3.15
and later and a .pth file before that, is described under
applying patches at interpreter startup
in the wrapt documentation. Install autowrapt 2.0.0 or later: it is
the first release to ship the .start file, and Python 3.15 onwards
phases out running code from .pth files in its favour.
Positioning matters here. Injection is a development, staging and break-glass tool: the unwritten rule for autowrapt is that it is not installed on production systems in normal situations, precisely because of what it enables, and that installation gate is the feature. Production tracing is the code-level path from earlier on this page, an application registering its own process sink at startup. Two operational cautions follow from the mechanism:
Injection never takes the process down. A missing config warns (
ConfigWarning) and the process starts untraced; a config that exists but cannot be applied warns the same way, because an error raised at bootstrap is fatal to an interpreter that has not even started, and the environment variable reaches every Python process launched under it, not just the one you meant. The loud failures belong to the runner and the programmatic path, where the caller chose to apply.Observe entries defer, so the bootstrap imports no application code: bindings land as the application imports its own modules, in its own order, and a Django models module is observed the moment Django itself brings it in. What does resolve at bootstrap is the
[[sink]]references, because a sink must exist before events flow; operator code it lives in must be reachable then, which is whatpythonpathis for.The variable’s reach is configurable.
inherit = falsein the config strips wrapture’s own name fromAUTOWRAPT_BOOTSTRAPonce the config applies, so Python processes the application launches by exec or spawn start untraced; other tools named on the variable are untouched, andWRAPTURE_CONFIGis left alone. The default inherits, because launched workers (a dev server’s autoreloader child, spawned pool workers) are usually the application itself. Forked workers are outside either setting: a fork inherits the parent’s memory, patches and sinks included, without re-running interpreter startup.
Once injected, the process is still operable. The bootstrap keeps
its AppliedConfig record on wrapture.bootstrap.applied, since
nothing else in an injected process holds it: from a console, a
debugger or a signal handler, report() lists what is installed and
what is still pending, suspend() and resume() are the runtime
toggle (wrappers stay in place, operations pass through and are
counted, and a pending entry firing while suspended arrives
suspended too), and revert() takes the whole intervention down
without restarting the application.
Forked worker processes
A fork inherits the parent’s memory, patches and sinks included, and
wrapture keeps recording coherent across it. The first sink
registration installs os.register_at_fork handlers, benign in a
process that never forks. Before a fork the process sinks are
flushed, so buffered output is not duplicated into the child, and
the recording locks are taken, so the child does not inherit one
held by a thread that no longer exists there; the parent releases
them afterwards, and the child reinitialises them fresh.
The child also discards the inherited in-flight stack, and with it
any active trace. This is structural, not just policy: the in-flight
events belong to the parent, which will run their bodies and close
them, so a child that kept the stack would nest its first event
under an operation completing in another process. Immediately after
a fork, current_event() and annotate() are in the
nothing-in-flight state until new work starts. The consequences fall
out of the trace kind gate: the child’s first operation is a genuine
root, a daemon worker’s next WSGI or ASGI request mints or joins a
trace as any root request does, and a child wanting an immediate
identity opens a block, which mints.
Sinks hear about the fork through on_fork(), a notification
delivered in the child only, with a do-nothing default; a sink
overrides it to rebuild what fork broke, since worker threads do not
survive into the child and inherited descriptors are shared with the
parent. JSONLines restarts its writer lazily and expands its path
template afresh, so a {pid} template gives each process a file of
its own; the OpenTelemetry sink drops its open-span table (the
parent’s spans are the parent’s) while the OTel SDK’s own at-fork
handling restarts the exporter threads. Children created by spawn or
exec are a different case and need none of this: they start fresh
and deliberately untraced, as the autowrapt section above describes.
Trace identity and propagation
wrapture’s event linkage, sequence numbers and parent ids, is
process local. A trace identity extends the tree across
processes: every tree of events carries a distributed trace id, the
W3C trace context, and the identity travels in the traceparent
header of outbound requests, so two services both observed by
wrapture join their trace files on one id, with or without any
tracing backend involved.
Every tree rooted in an operation is a trace. The mechanism is
on by default: a root call, request or block event that inherited no
context mints a fresh identity, one random id per tree, only while
events are being recorded at all, so the cost when nothing listens
stays nothing. Traces start at declared operation boundaries, a
function invoked, a request arriving or a block entered, not at
accesses or messages: a tree rooted in an attribute event carries no
identity, and a log message never starts a trace, though one
recorded inside a traced tree shares its identity like any other
child. Children share their tree’s context, readable in code as
event.trace, and every JSONLines line carries the identity under
its trace key:
{"seq": 3, "path": "backend:quote", "trace": {"w3c": {"trace_id": "1f4c24c6b0e14f6a9d2c8e5a7b3f9d10", "sampled": true}}}
The WSGI and ASGI middleware
are the special case at the ingress boundary: an arriving request
carrying a traceparent header joins the caller’s distributed trace
rather than minting, and its tracestate rides along untouched. A
request with no recognised headers mints as any root does. A
boundary inside an already-traced tree keeps the enclosing identity
unless it receives headers of its own, which start a fresh scope for
its subtree.
The same join is available to any boundary the middlewares do not
speak for. block(name, joins=headers) parses the identity a
synchronous ingress arrived with, an XML-RPC dispatcher or a raw
socket server say, and the block’s tree becomes part of that
distributed trace by exactly the middleware rules: joining at a
root, shading a subtree when nested, minting as usual when the
mapping carries nothing valid. Where links= records a hand-off
from another operation (the consuming side of a queue), joins=
says the caller is still waiting: same trace, not a related one.
Processes with no HTTP ingress get their trace root from a block: a
cron job, CLI command or queue worker wraps its operation in
with wrapture.block("process-batch"): and the root block mints,
trace_headers() supplying the identity for whatever it sends
outward. The block’s name can even be keyed dynamically
(block(f"deploy {target}")) where a function name is fixed, which
is why blocks mint rather than deferring to an @observed function
at the top; a request with incoming headers nested under such a root
still joins its own trace, since ingress parsing at the boundary is
independent of root minting.
On the way out, instrumentation injects the identity into outbound
traffic. The whole public surface such an instrumentation needs is
two functions. wrapture.current_trace() answers “what trace is this
operation part of”, carrier-agnostic: an instrumentation for a
transport with no header concept (trace context in a SQL comment, a
database session variable) reads the slot’s ids off it and renders
them its own way. wrapture.trace_headers() is the convenience for any
carrier of named values, HTTP request headers foremost but equally
message-queue headers or gRPC metadata: it returns the pairs an
outbound message made right now should carry, empty when nothing is
being recorded, so injection is always safe to attempt. Such an
instrumentation is one Instrumentation class with one behaviour
stage; the trace-propagation example’s urllib_support.py is the
complete pattern, a client and a server in two processes sharing ids
through nothing but the header.
W3C trace context is the one wire format wrapture speaks. The
ecosystem has converged on it, and vendor baggage rides inside the
standard’s own tracestate header, so there is nothing left for a
second convention to carry. One invariant governs an identity
wrapture parses but nothing claims: never break a trace you do
not understand. An arriving identity keeps its raw headers on the
slot, and on egress a slot no tracing sink has claimed forwards
those headers verbatim, so the upstream product sees this service
as a transparent hop and its trace stays connected, while a claimed
slot is rewritten with span ids that really get exported. Headers
wrapture does not parse are never touched at all: a request
carrying a vendor format alongside traceparent (services
mid-migration send both) flows through the application with no
wrapture involvement in the vendor headers whatsoever.
Configuration is the top-level [trace] table, and the noun is
deliberate: this switches trace identities, never observation,
recording or sinks:
[trace]
enabled = false # ids and propagation off process-wide
[[observe]]
target = "myapp.jobs"
match = "run_*"
trace = true # these roots mint identities anyway
trace = true on an observe entry is the case-by-case re-enable
under a global disable: that entry’s roots, a background job wanting
an identity for its outbound calls, say, mint even while the
mechanism is off elsewhere. Because only operations mint, the flag
lands on the entry’s call and request bindings; an entry that binds
nothing but attributes is rejected, since the flag could never act
there. With no [trace] table at all, the default stands: enabled.
Work the caller does not wait for
Nesting says one operation ran inside another, and on a single
thread that is always the truth: a call made while another is in
flight is its child, whatever it goes on to do. A hand-off breaks
that rule. A request that starts a thread and returns, a pool
worker that runs a job long after its submitter has finished, a
consumer that processes a message some other process put on a
queue: the work was caused by an operation but is not contained by
it, and drawing it as a child would make the parent’s duration a
lie and, once the parent has closed, leave the child with nothing
to attach to. wrapture spells the relationship with a link: a
root event carrying, on event.links, the identity of the
operation that handed it off, without being nested under it.
For a thread, detach() is the hand-off, the sibling of
propagate(). Where propagate() copies the recording context so
the thread’s events nest under the caller’s in-flight event (right
when the caller waits for the thread), detach() clears the
in-flight stack in the copy and remembers the origin, so every
event the thread records with nothing in flight above it becomes a
root of its own, minting its own trace identity, with a link back:
@wrapture.observed
def handle_upload(request):
store(request.file)
threading.Thread(
target=wrapture.detach(generate_thumbnails), args=(request.file,)
).start()
return "accepted"
@wrapture.observed
def generate_thumbnails(file):
for size in SIZES:
resize(file, size)
On the tape that is two trees, and tree() names the origin on the
detached root:
handle_upload(request=<Request>) -> 'accepted' [12.3ms]
generate_thumbnails(file=<File>) -> None <- handle_upload [840ms]
resize(file=<File>, size=32) -> ...
resize(file=<File>, size=64) -> ...
handle_upload’s duration is its own 12.3ms, not the 850ms it
would show as a parent, and tape.detached_from(upload) and
tape.origins_of(thumbs) walk the relationship in either direction
for a test to assert on. detach() records nothing itself and takes
no name or data: which events the thread records, and what they are
called, is decided as always by the bindings on what the target
calls, by @observed on the target, or by a block() in its body.
The example observes generate_thumbnails so the thread’s work is
one tree; had it not been, each resize would be a root of its own,
every one of them linked back to the upload, since the origin holds
for the life of the detached context rather than for its first
event only. pool.submit(wrapture.detach(job), ...) is the same
call for an executor, and a detached thread outliving the timeline
is discarded and counted exactly as a propagated one is.
For a queue, the hand-off crosses a process boundary and the link
travels in the message. handoff() captures the in-flight event as
the origin, and its headers() are the traceparent pairs to put
on the message, the same pairs trace_headers() gives; on the
consuming side, block(links=...) names the message’s headers and
the root block links to the producer’s operation rather than
nesting under it, an EventLink with no sequence number since the
origin ran elsewhere:
# producer
@wrapture.observed
def enqueue_report(job):
origin = wrapture.handoff()
queue.put({"job": job, "headers": origin.headers() if origin else {}})
# consumer
def worker():
while True:
message = queue.get()
with wrapture.block("report", links=[message["headers"]]):
build_report(message["job"])
A mapping with no valid traceparent contributes nothing, so the
headers of an untraced message are safe to pass. Links belong to
roots: links= on a block nested inside an in-flight operation is
dropped with a ConfigWarning, because a nested block is that
operation’s child and the same-thread rule is not for overriding.
And links are for hand-offs, where the producer has moved on; a
server answering a waiting caller joins the caller’s trace instead,
with block(joins=headers), described under trace identity and
propagation.
Every renderer carries the relationship. JSONLines lines have a
links list with the origin’s ids, its seq when it ran in this
process, and any attributes noted on the link; chrome_trace()
joins a detached root to an origin in the same file with a flow
arrow between the lanes; canonical() appends <- path for an
origin in the same trace; the Printer marks the root with its
origin’s trace id. With a tracing sink active the ids captured at
the hand-off are those of the span the origin was exported as,
which is what makes the OpenTelemetry sink’s
translation to span links land on a span that exists.
Exporting traces to other tools
Three exporters render a trace for existing tools rather than a
viewer of wrapture’s own. Each accepts either a Tape or event
records in the serialised JSONLines form, so a trace can be exported
live inside a test or long after the fact from a file the runner
produced; load_events(path) reads such a file back. From a shell,
python -m wrapture.tools convert does the same without writing any
code (bare python -m wrapture.tools lists the available commands).
A tracing backend with a span model of its own is fed live by a sink
instead; the OpenTelemetry bridge closing this section is the worked
case.
A timeline in Perfetto: chrome_trace
chrome_trace() renders the trace in Chrome trace JSON, the format
the Perfetto UI (and the older
chrome://tracing) opens directly:
$ python -m wrapture.tools convert --format chrome -o trace.json trace.jsonl
Drop trace.json onto Perfetto and the trace becomes a navigable
timeline: one lane per thread, one slice per event, nested slices
for nested events, with widths proportional to duration. Clicking a
slice shows the captured arguments, result or exception in the
detail pane. The gaps between slices are unobserved time, which for
a deliberately sparse trace is itself information. An event that
never closed appears as a begin with no end, and a generator’s slice
spans creation to close with its accumulated body time alongside.
Work handed to another thread with detach() sits on that thread’s
lane with a flow arrow from the operation that started it.
Architectural snapshots: canonical
canonical() renders the call tree as a deterministic text
fingerprint: kind and path per line, indented by nesting, !! with
the exception type for failures, (injected) for outcomes supplied
by behaviour, <- path on a root handed off from an operation in
the same trace. Everything unstable between runs (sequence numbers,
timings, captured values, thread identity) is left out:
with wrapture.timeline(place, charge, ledger) as tape:
checkout(order)
assert wrapture.canonical(tape) == snapshot
Snapshot it once (a golden file, or a tool such as syrupy) and a
refactor that silently changes what calls what fails the comparison
as a diff a reviewer can read. This is the complement to
assert_order: the assertion states the rules you thought of, the
snapshot catches the changes you did not. From a shell,
convert --format canonical renders a trace file the same way.
Diagrams where Mermaid renders: mermaid
mermaid() renders the trace as a sequence diagram: participants
are the classes and modules, messages are the members called on them
in recorded order, failures return their exception type, and
attribute events carry their kind (status (set)). Mermaid renders
natively on GitHub and in most documentation tooling, so the output
pastes straight into a pull request comment;
convert --format mermaid writes it to standard output ready for
the clipboard. Best kept to small traces; sequence diagrams stop
being readable beyond a few dozen events.
sequenceDiagram
participant caller
participant P1 as OrderService
participant P2 as PaymentGateway
caller->>+P1: place_order
P1->>+P2: charge
P2-->>-P1: TimeoutError
P1-->>-caller: return
Live traces in OpenTelemetry: a sink
The converters above render a trace after the fact, from a tape or a
file. Feeding a tracing backend while the application runs is a job
for a sink, and OpenTelemetry’s is one wrapture ships: the
OpenTelemetry export guide covers the [otel]
config table, the three signals, the trace identity claiming and
the wrapture[otel] extra. What stays here is the shipped sink as
the worked case for a sink of your own
feeding any backend with a span model; the pattern is the same,
only the API names change.
Most of the mapping writes itself, because the two models are close:
One event becomes one span, opened at
on_enterand closed at exactly one ofon_exitoron_error, the error close recording the event’s exception on the span and marking its status. The event’s links, the origins of work handed off withdetach()or named by a consumer block, become the span’s links, set at creation because that is the only moment OTel accepts them."request"events become SERVER spans named access-log style (GET /quote/widget), carrying the method, path and status code under their semantic-convention attribute names;"call"events become INTERNAL spans named by the binding, or, for a categorised call, by its contract keys (SELECT orders,send notifications,inventory.Counter/Count), and"block"events become INTERNAL spans named by the block, which is how an embeddedwith wrapture.block("render-invoice"):shows up as a span with no OTel API in the code. Attribute events are skipped as too fine-grained for a trace, the same judgement thekindfilter spells in a config file.Captured arguments, results and
annotate()data become span attributes, flattened onto dotted names (wrapture.arg.item,wrapture.data.rows) because OTel attributes hold only scalars and lists of scalars. The data dict is read again when the event closes, since annotations merge in while the operation runs. The sink declares"summary"capture on both axes, so the values reaching it are already bounded text and scalars, safe to hand to an exporter and impossible to retain live objects through.flush()forwards to the span processor’sforce_flush(), so the flush wrapture gives process sinks at interpreter exit also drains OTel’s batch processor, and the tail of a trace is not lost in its buffer.
Two moves are less obvious, and they are the part worth copying into
a bridge to any other backend. The first is parenting. OTel normally
parents spans through ambient context, but sink notifications arrive
on whatever thread ran the observed operation, and a generator or a
streamed response body can close on a different thread than it
started on, so the ambient context at the close is not reliably the
one from the start. The event’s parent_id is correct in all of
those cases, so the sink parents explicitly through a map of open
spans:
def on_enter(self, event):
context = None
if event.parent_id is not None:
with self._lock:
entry = self._spans.get(event.parent_id)
if entry is not None:
context = trace.set_span_in_context(entry[0])
span = self._tracer.start_span(
name=event.label or event.path,
context=context,
start_time=self._to_epoch_ns(event.started),
...
)
with self._lock:
self._spans[event.seq] = (span, time.monotonic())
The close pops the entry by event.seq and ends the span, so the map
holds only what is in flight. Children close before the operation
that contains them, which is what makes the lookup safe.
The second is time. Events are stamped on the perf_counter clock,
which has an arbitrary zero; OTel wants absolute epoch nanoseconds.
Sampling both clocks once, in the same breath at construction, pins
them together, and every event time then converts by one addition:
self._epoch_offset_ns = time.time_ns() - int(time.perf_counter() * 1e9)
def _to_epoch_ns(self, perf_seconds):
return self._epoch_offset_ns + int(perf_seconds * 1e9)
Passing start_time and end_time through this conversion means the
span timings are the event timings, not the moments the exporter
heard about them.
Everything else about the shipped sink, the [otel] table and its
per-signal tuning, the trace identity claiming that makes files,
headers and exported spans agree on one id, the metrics and logs
signals, the pipeline posture, and the fork behaviour, lives in the
OpenTelemetry export guide.