ADR-0016: Optional Tracing, No-Op by Default¶
Every durable component in this library can emit OpenTelemetry spans -- event stores, event buses, aggregate repositories, snapshot stores, projections, read models, distributed locks, and the live-migration tooling all accept a tracer and an enable_tracing flag. None of them require OpenTelemetry to be installed, and none of them pay for tracing when it is switched off.
This ADR records how that is arranged: OpenTelemetry lives behind the telemetry extra, a single OTEL_AVAILABLE probe in eventsource/observability/tracing.py is the only place the import is attempted, and NullTracer -- a null object satisfying the same Tracer protocol as OpenTelemetryTracer -- absorbs the disabled case so that call sites contain a plain with self._tracer.span(...) and no conditional. It also records the seams that fell out of that choice (MockTracer, SpanKindEnum, the eventsource.* attribute catalogue) and the asymmetries the design deliberately tolerates, chiefly that enable_tracing does not default the same way in every subsystem.
Status¶
Status: Accepted
Date: 2026-07-27 (records a decision in force since 0.5.0)
Deciders: Library maintainers (architecture owner, observability owner)
The arrangement is fully in effect in the current tree. opentelemetry-api and
opentelemetry-sdk appear only under the telemetry extra in pyproject.toml
(and transitively in the all extra); neither is a runtime dependency of a
plain pip install eventsource-py. OTEL_AVAILABLE, NullTracer,
OpenTelemetryTracer, MockTracer, SpanKindEnum, and create_tracer are all
exported from eventsource.observability, and the components listed above take
tracer / enable_tracing through their constructors.
One part of the decision is only partly landed. Item 4 (composition through the
Tracer protocol) replaced the earlier inheritance-based TracingMixin, which
was removed in commit 0f70002; call sites that used to inherit it now hold a
tracer and carry a # Composition-based tracing (replaces TracingMixin)
comment. The older traced decorator in observability/tracing.py survives
alongside the protocol and still reads _tracer / _enable_tracing attributes
off self, so both styles coexist. That is recorded below as an accepted
asymmetry rather than as unfinished work; superseding the decorator would be a
separate decision.
No ADR supersedes this one. It should be revisited if the traced decorator is
retired, if metrics (currently guarded separately in
subscriptions/metrics.py) are folded into the same protocol, or if
enable_tracing defaults are ever unified across subsystems.
Amended by ADR 0025. Per-operation store spans (inmemory_event_store.*,
postgresql_event_store.*, sqlite_event_store.*) were removed along with the
legacy stores; the ports adapters that replaced them carry no spans of their
own, and a ports-level tracing decorator is backlogged rather than promised.
Amended by ADR 0064.
Item 9's naming rule stands. Its claim that the constant list in
attributes.py "doubles as the catalogue of what this library will put on a
span" was aspirational rather than descriptive: several constants named below,
ATTR_DB_NAME among them, had no emitting site anywhere in the tree. 0064
deletes the never-emitted constants and makes the catalogue descriptive, and
replaces the hand-written literal lock attribute keys in the PostgreSQL lock
manager with the declared eventsource.lock.* constants.
Context¶
OpenTelemetry is a heavy dependency for a library with a two-package core¶
pyproject.toml declares exactly two runtime dependencies: pydantic>=2.0,<3.0
and sqlalchemy>=2.0,<3.0. Everything else -- asyncpg (postgresql), aiosqlite
(sqlite), redis (redis), aio-pika (rabbitmq), aiokafka (kafka), and
OpenTelemetry (telemetry) -- sits behind a named extra, with an all extra
that pulls the set together.
Tracing is not a domain-modelling concern, so making OpenTelemetry mandatory
would be an odd third entry in that two-package core. The telemetry extra is
also not one package but two -- opentelemetry-api>=1.0,<2.0 and
opentelemetry-sdk>=1.0,<2.0 -- because a user who wants spans actually
exported needs the SDK, not just the API. Those two version in lockstep, and a
library that hard-pins them is a recurring source of resolver conflicts in
applications that already carry their own observability stack, frequently a
different OTel distribution or an auto-instrumentation bundle. A library should
not get a vote in that argument.
The pyproject.toml mypy configuration reflects the same stance: there is an
override for module = "opentelemetry.*" so type checking still succeeds in an
environment where the package is absent.
At the same time, spans are the difference between a usable and an unusable event-sourced system in production. Append/publish/project is a distributed pipeline, and "which command produced this projection lag" is a trace question. So the library cannot simply decline to instrument; it has to instrument thoroughly while remaining installable without the instrumentation stack.
Tracing call sites are hot paths (append_events, publish, project)¶
Tracing is not sprinkled lightly. The current tree constructs a tracer at 58
sites via create_tracer(...) and opens spans at 274 self._tracer.span(...) /
.span_with_kind(...) / .start_span(...) call sites spread over 46 modules.
The distribution is lopsided toward the machinery that runs per event:
migration/ accounts for 83, repositories/ (checkpoint, DLQ, outbox) for 69,
readmodels/ for 43, then bus/, snapshots/, and subscriptions/ at 16
each, projections/ at 11, stores/ at 8, aggregates/ at 7, locks/ at 2.
Those are not startup paths. PostgreSQLEventStore.append_events opens its span
before doing any work and delegates the body to _do_append_events; every
EventBus.publish implementation wraps the send; Projection.handle opens
eventsource.projection.handle per event and then opens a second
eventsource.projection.handler span per dispatched handler. A single command
that appends three events and fans out to four projections therefore crosses a
double-digit number of these call sites.
That volume creates two pressures, and they point in opposite directions.
First, whatever guard protects a call site is paid on every event, so it has to
be cheap in the disabled case -- which is the overwhelmingly common case, since
most installs will not have the telemetry extra at all. The cost that matters
is not span creation (there is none when tracing is off) but the guard itself
plus the argument evaluation that happens before the guard can run. Note the
shape of the store call site: the attribute dict, including
",".join(type(e).__name__ for e in events), is built as an argument and is
therefore evaluated before span() is entered, no matter what span() then
decides to do. Any design where the disabled path still constructs those
dictionaries loses on a hot loop.
Second, whatever the guard looks like is repeated 274 times, so it has to be
short. A per-call-site try/except ImportError, or
if self._tracer is not None and self._enable_tracing: wrapping every span,
would become the most-repeated construct in the codebase; on methods whose
traced body is one delegating line, it would dominate the method. It would also
be 274 chances to get the condition subtly wrong in one backend and silently
lose instrumentation there.
These two pressures are why the design needs both a null object (so the common
call site is an unconditional with self._tracer.span(...), no branch, no
import guard) and a cheap enabled predicate (so the handful of sites with
genuinely expensive attribute computation or context propagation can skip the
work). The codebase uses the second sparingly and deliberately: components cache
self._enable_tracing = self._tracer.enabled once in __init__ rather than
re-querying the property, and only eleven sites branch on it -- concentrated in
bus/kafka.py and bus/rabbitmq.py, where the guard also has to cover
PROPAGATION_AVAILABLE before extracting or injecting W3C trace context from
message headers.
The existing traced decorator requires _tracer / _enable_tracing attributes on the class¶
The first tracing mechanism in the library is the traced(name, attributes=...)
decorator in observability/tracing.py, alongside two helpers from the same
module: get_tracer(name), which returns an OTEL tracer or None, and
should_trace(enable_tracing), which is enable_tracing and OTEL_AVAILABLE.
traced builds a sync or an async wrapper, picks between them with
asyncio.iscoroutinefunction(func), and at call time reads state off the
instance:
tracer = getattr(self, "_tracer", None)
enable_tracing = getattr(self, "_enable_tracing", False)
if not enable_tracing or tracer is None:
return func(self, *args, **kwargs)
with tracer.start_as_current_span(name, attributes=attributes or {}):
return func(self, *args, **kwargs)
That contract -- "the decorated method's class must have _tracer and
_enable_tracing attributes" -- is the constraint this section is about, and it
is what kept the decorator from becoming the library's tracing mechanism.
It has three problems, in increasing order of severity.
The contract is implicit and unenforced. getattr with defaults of None and
False means a class that never sets _enable_tracing does not fail, does not
warn, and simply never traces. There is no type, no protocol, and no
construction-time check that would catch the omission; the only symptom is
missing spans in production, discovered by whoever goes looking for them. The
defaults are chosen to fail safe, which is correct behaviour and also exactly
what makes the failure invisible.
Attributes are static. attributes is bound at decoration time and passed
straight through to start_as_current_span, so the decorator can express
attributes={"db.system": "sqlite"} but nothing that depends on the call.
Almost every span in this library wants the opposite: the aggregate id, the
stream name, the event count, the expected version, the tenant, the topic. The
docstring concedes the point and redirects the reader to a mixin method for
"dynamic attributes that depend on method arguments" -- a method that no longer
exists (see below). A decorator that cannot see the arguments cannot carry the
attributes that make a span worth reading.
It presumed inheritance. The _tracer / _enable_tracing pair was originally
supplied by a TracingMixin base class, which the docstrings still reference.
That put tracing in the MRO of every store, bus, and projection -- a
cross-cutting concern occupying a base-class slot in classes that already have
real hierarchies (EventStore implementations, DeclarativeProjection), and
one that cannot be swapped per instance for a test.
The decorator's present status is worth stating plainly, because it bears on
what the Decision does not do: @traced has no production call sites. Every
occurrence in the tree is a docstring example (in observability/__init__.py
and tracing.py) or a test (tests/unit/observability/test_tracing.py,
which covers enabled, disabled, missing-attribute, sync, async, and
static-attribute cases). It remains exported from eventsource.observability
and remains supported; it is simply not how the library traces itself. The
TracingMixin it was designed around is gone, replaced by composition, and the
call sites that used it now hold a tracer object -- as their
# Composition-based tracing (replaces TracingMixin) comments record, in
bus/memory.py, projections/base.py, projections/coordinator.py,
subscriptions/manager.py, migration/dual_write.py, and elsewhere.
So the question the Decision has to answer is not "how do we make the decorator cheaper" but "what replaces an implicit attribute contract that cannot carry dynamic attributes" -- while keeping the disabled path free at the 274 span sites counted above.
Prior art in the codebase: the *_AVAILABLE optional-dependency pattern¶
The library already had an established way to handle an optional backend:
attempt the import at module scope inside try/except ImportError, set a
module-level *_AVAILABLE boolean, and rebind the missing names to None (or
to a harmless stand-in) so the module still imports. The tree currently has
eight such flags -- KAFKA_AVAILABLE, RABBITMQ_AVAILABLE, REDIS_AVAILABLE,
SQLITE_AVAILABLE (three separate copies, in __init__.py, snapshots/, and
stores/ as _SQLITE_AVAILABLE), OTEL_METRICS_AVAILABLE (in
subscriptions/metrics.py, subscriptions/shutdown.py, and
migration/metrics.py), PROPAGATION_AVAILABLE, and OTEL_AVAILABLE.
observability/tracing.py follows exactly that shape, and labels itself as the
canonical instance:
# Optional OpenTelemetry import - single source of truth
try:
from opentelemetry import trace
OTEL_AVAILABLE = True
except ImportError:
OTEL_AVAILABLE = False
trace = None # type: ignore[assignment]
Reusing the pattern kept tracing consistent with the rest of the optional
surface, gave the probe one home, and made it exportable -- OTEL_AVAILABLE is
re-exported from eventsource.observability and imported from there by
observability/tracer.py, bus/kafka.py, and bus/rabbitmq.py.
But the backend pattern stops where tracing has to keep going, and the difference is the failure policy attached to a false flag.
For a backend, *_AVAILABLE == False is a fatal, local, construction-time
error. SQLiteSnapshotStore.__init__ opens with if not SQLITE_AVAILABLE: raise
SQLiteNotAvailableError(); KafkaEventBus.__init__ does the same with
KafkaNotAvailableError. Both exceptions subclass ImportError and carry the
pip install hint. There is one check, in one constructor, and the user asked
for that backend by name -- so failing loudly is the right answer, and the flag
is consulted exactly once per class.
For tracing, OTEL_AVAILABLE == False must be non-fatal, global, and silent.
Nobody asked for tracing by naming a class; they got a PostgreSQLEventStore
that happens to want to emit spans. Raising would make the telemetry extra a
de-facto core dependency, defeating the point. And the check is not needed once
in one constructor but at the 274 span sites counted above, in 46 modules across
every subsystem -- so the check cannot be spelled out at the point of use the way
if not KAFKA_AVAILABLE: raise can.
The two other OTel-adjacent flags show what the unextended pattern degenerates
into when a subsystem does its own guarding. OTEL_METRICS_AVAILABLE appears
three times, once per metrics module, because metrics have no null object; each
site pays its own if not OTEL_AVAILABLE: return None (see _get_meter in
bus/kafka.py). And PROPAGATION_AVAILABLE in bus/kafka.py and
bus/rabbitmq.py is a second import guard over opentelemetry.propagate and
opentelemetry.metrics, defined as PROPAGATION_AVAILABLE = OTEL_AVAILABLE
inside its own try -- a flag deriving from the canonical one because those
modules need symbols (inject, extract, SpanKind) that the tracing facade
does not expose. Those are the parts of the codebase where the raw pattern is
still visible, and they are conspicuously the least pleasant to read.
So the prior art supplies the availability probe and nothing more. The mismatch it leaves open -- one flag, but a "raise immediately" policy for backends versus a "degrade invisibly, everywhere, for free" policy for tracing -- is the specific problem the rest of this ADR resolves, and it is why the decision below adds a null object on top of the pattern rather than simply adopting the pattern again.
Decision¶
Tracing is provided through a null object behind a Protocol, constructed by a
factory, gated by a per-component flag. OpenTelemetry stays in the telemetry
extra; the disabled case is represented by an object rather than by a branch;
and the object is injected by composition so it can be swapped per instance.
The nine numbered items below are the whole of the decision. Items 1--3 solve
the availability/cost problem stated in the Context; items 4--7 fix the seams
the traced decorator could not; items 8--9 keep the OpenTelemetry vocabulary
from leaking into calling code.
1. OpenTelemetry ships as the telemetry extra, never a core dependency¶
opentelemetry-api>=1.0,<2.0 and opentelemetry-sdk>=1.0,<2.0 are declared
under [project.optional-dependencies] telemetry and are reachable only through
that extra or through all, which composes it. The runtime dependency list
stays at pydantic and sqlalchemy. pip install eventsource-py gets a fully
functional, fully instrumented-in-source library that emits nothing; pip
install eventsource-py[telemetry] turns the same code into a span emitter with
no source change.
The corollary is that the library never raises because OpenTelemetry is missing.
OpenTelemetryTracer.__init__ does import opentelemetry and will raise
ImportError if it is absent, but nothing constructs it unless OTEL_AVAILABLE
is already true (item 5). This is the opposite policy from the optional
backends, where KafkaEventBus.__init__ deliberately raises
KafkaNotAvailableError: a user who names KafkaEventBus asked for Kafka,
whereas nobody asks for tracing by naming PostgreSQLEventStore.
The mypy configuration completes the arrangement with an
ignore_missing_imports = true override for module = "opentelemetry.*", so
mypy src/eventsource/ passes in an environment without the extra installed.
2. A single OTEL_AVAILABLE probe in observability/tracing.py is the only import guard¶
observability/tracing.py performs the import once, at module scope, and
comments itself as the "single source of truth":
try:
from opentelemetry import trace
OTEL_AVAILABLE = True
except ImportError:
OTEL_AVAILABLE = False
trace = None # type: ignore[assignment]
Every other module that needs to know imports the flag rather than re-attempting
the import: observability/tracer.py imports it to implement create_tracer,
and eventsource.observability re-exports it as public API.
Two caveats, stated here because the rule is narrower than its heading suggests.
The probe is the only import guard for tracing, not for everything OTel. Two
other flags exist for capabilities the tracing facade does not cover.
PROPAGATION_AVAILABLE, in bus/kafka.py and bus/rabbitmq.py, guards
opentelemetry.propagate (inject / extract) for W3C context propagation
through message headers and is defined as PROPAGATION_AVAILABLE = OTEL_AVAILABLE
inside its own try. OTEL_METRICS_AVAILABLE, in subscriptions/metrics.py,
subscriptions/shutdown.py, and migration/metrics.py, guards
opentelemetry.metrics; metrics have no null object, so each of those modules
pays its own if not ...: return None. Both are deliberate: this ADR governs
spans, and extending the null-object treatment to metrics and propagation is a
separate decision.
Second, the flag reports installability, not configuration. OTEL_AVAILABLE
being true says the package imports, not that an SDK, exporter, or collector is
wired up. That distinction resurfaces in OpenTelemetryTracer.enabled (see
Consequences).
3. NullTracer as a null object instead of try/except or if tracer is not None at each call site¶
NullTracer implements the full Tracer surface and does nothing: span() and
span_with_kind() are @contextlib.contextmanager generators whose entire body
is yield None, start_span() returns None, and enabled returns False.
It is never absent and never None, so the 274 span sites counted in the
Context are written unconditionally:
with self._tracer.span("eventsource.event_store.append_events", {...}):
await self._do_append_events(...)
No try, no if, no getattr. Whether the process has OpenTelemetry installed,
has it installed but disabled for this component, or is emitting real spans, the
call site is the same text — which is what makes it tolerable to repeat 274
times across 46 modules, and what removes 274 chances to get a per-site
condition subtly wrong in one backend.
The cost in the disabled case is a generator-based context manager per span,
plus evaluation of the attribute dictionary that was passed as an argument.
Building the dict is not avoided by the null object, since arguments are
evaluated before the call. That residual cost is what item 6 and the enabled
predicate exist to address at the handful of sites where the attributes are
genuinely expensive; everywhere else, a dict literal on the event path is
accepted as the price of a branch-free call site.
NullTracer is also the default fallback everywhere a tracer is optional
(tracer or create_tracer(...), and tracer or NullTracer() in the documented
component pattern), so a component can never hold None where a tracer belongs.
4. Tracer is a runtime_checkable Protocol, injected by composition¶
Tracer is a @runtime_checkable Protocol in observability/tracer.py with
four members: span(name, attributes=None), the enabled property,
start_span(name, kind, attributes=None, context=None), and
span_with_kind(name, kind, attributes=None, context=None). NullTracer,
OpenTelemetryTracer, and MockTracer satisfy it structurally — none of them
inherit from it.
Components take a tracer as a constructor parameter, not a base class:
def __init__(self, ..., tracer: Tracer | None = None, enable_tracing: bool = True):
self._tracer = tracer or create_tracer(__name__, enable_tracing)
self._enable_tracing = self._tracer.enabled
That signature — optional tracer, plus enable_tracing — is the standard shape
across stores, buses, snapshot stores, repositories, projections, read models,
locks, subscriptions, and the migration package. It replaces the earlier
TracingMixin, removed in 0f70002; the sites that inherited it now carry a
# Composition-based tracing (replaces TracingMixin) comment. Tracing is out of
the MRO of every EventStore, EventBus, and DeclarativeProjection, and a
tracer can be swapped per instance rather than per class.
runtime_checkable makes isinstance(x, Tracer) legal for callers assembling
components dynamically. It only checks member presence, not signatures — an
accepted limit of Python protocols, and the reason the type annotation, not the
isinstance check, is the real contract.
5. create_tracer(name, enable_tracing) is the single construction path¶
def create_tracer(name: str, enable_tracing: bool = True) -> Tracer:
if enable_tracing and OTEL_AVAILABLE:
return OpenTelemetryTracer(name)
return NullTracer()
Both conditions in one place, evaluated once per component at construction. This
is why OpenTelemetryTracer.__init__'s from opentelemetry import trace can
never fire an ImportError in normal use, and why no component ever spells out
if OTEL_AVAILABLE itself. The return type is the Tracer protocol, so callers
cannot depend on which implementation they received.
name is conventionally __name__, so spans are attributed to the module that
emitted them. enable_tracing defaults to True at the factory: the factory's
default is "trace if you can", and any component that wants a different policy
sets its own parameter default (item 6).
An explicitly passed tracer bypasses the factory entirely — that is the seam
item 7 relies on.
6. Per-component enable_tracing flags gate tracing independently of OTEL availability¶
There is no global tracing switch. Each traced component exposes its own
enable_tracing: bool constructor parameter, and the two gates are orthogonal:
OTEL_AVAILABLE answers "can this process trace at all", enable_tracing
answers "should this component trace". Turning tracing off for one noisy
projection while leaving the event store instrumented requires no global state
and no environment variable, which also means two stores in the same process can
disagree — useful in tests and in multi-tenant hosts.
Components cache the resolved answer once: self._enable_tracing =
self._tracer.enabled, read from the tracer rather than from the constructor
argument, so the cached flag reflects availability as well as intent. That
cached boolean is the cheap predicate the hot-path sites use before computing
expensive attributes or doing context propagation — around eleven sites,
concentrated in bus/kafka.py and bus/rabbitmq.py where the guard must also
cover PROPAGATION_AVAILABLE.
The defaults are deliberately not uniform. True in stores, buses,
aggregates, snapshots, repositories, locks, subscriptions, and migration; False
in projections/base.py, projections/coordinator.py,
projections/dlq_manager.py, projections/checkpoint_manager.py, and
readmodels/projection.py. Projection code fans out per event per projection
and is the highest-volume span producer in the library, so it opts in. This is a
real inconsistency in the public API and is recorded as such under Consequences
rather than defended as elegant.
7. MockTracer is the supported test seam¶
MockTracer ships in observability/tracer.py and is exported from
eventsource.observability as public API — not a test fixture that happens to
be importable. It satisfies Tracer, appends (name, attributes) to a
self.spans list from all three span methods, exposes span_names for concise
assertions and clear() for reuse, and reports enabled as True.
Because tracers are injected (item 4), a test asserts on tracing by passing one in:
tracer = MockTracer()
store = PostgreSQLEventStore(..., tracer=tracer)
await store.append_events(...)
assert "eventsource.event_store.append_events" in tracer.span_names
No OpenTelemetry install, no SDK, no in-memory exporter, no monkeypatching of
OTEL_AVAILABLE. This is what per-backend tracing suites use
(tests/unit/test_redis_event_bus_tracing.py,
tests/unit/snapshots/test_snapshot_store_tracing.py,
tests/unit/aggregates/test_repository_tracing.py,
tests/unit/repositories/test_checkpoint_tracing.py, and
tests/unit/observability/test_tracer.py).
enabled returning True is the load-bearing detail: it makes tests take the
same branches production takes with tracing on, so attribute-computation code
guarded by self._enable_tracing is actually exercised. Its cost — that
MockTracer is not a faithful model of a disabled tracer — is why NullTracer
is separately available for tests that want the off path.
8. SpanKindEnum mirrors OTEL SpanKind so callers never import opentelemetry¶
SpanKindEnum is a plain Enum with INTERNAL, PRODUCER, CONSUMER,
CLIENT, and SERVER — the same five kinds OpenTelemetry defines, as
lowercase-string values. It is exported from eventsource.observability and is
what start_span and span_with_kind accept.
The translation happens in one place, inside OpenTelemetryTracer, which maps
the enum to opentelemetry.trace.SpanKind through a dict lookup with
OtelSpanKind.INTERNAL as the fallback, importing OTel inside the method
rather than at module scope. A caller that wants a PRODUCER span for a RabbitMQ
publish writes kind=SpanKindEnum.PRODUCER and imports nothing from
OpenTelemetry — which is required, not merely tidy, since in a telemetry-less
install that import would fail.
The mirror is intentionally lossy: it covers span kind and nothing else. Span
status, links, and events are still expressed by calling methods on the yielded
span object, which is None when tracing is off, so those call sites carry the
if span: guard the null object cannot remove.
9. Attribute naming: eventsource.* for domain concepts, OTEL semantic conventions for db.* and messaging.*¶
Every attribute key is a named constant in observability/attributes.py, and
the module splits on a single rule: if OpenTelemetry has already standardized
the concept, use its key; otherwise namespace under eventsource.
Standardized keys are reused verbatim — ATTR_DB_SYSTEM = "db.system",
ATTR_DB_NAME = "db.name", ATTR_DB_OPERATION = "db.operation",
ATTR_MESSAGING_SYSTEM = "messaging.system", ATTR_MESSAGING_DESTINATION =
"messaging.destination", ATTR_MESSAGING_OPERATION = "messaging.operation" —
so that existing APM backends light up their database and messaging views for a
PostgreSQLEventStore or a KafkaEventBus with no per-user mapping config.
Everything the specification has no opinion about is prefixed:
eventsource.aggregate.id, eventsource.aggregate.type, eventsource.event.id,
eventsource.event.type, eventsource.event.count, eventsource.version,
eventsource.expected_version, plus the lock, migration, projection, handler,
and tenant families. The prefix guarantees no collision with a future
db.*/messaging.* convention or with an application's own attributes.
Constants rather than string literals is the second half of the rule: a renamed
key is one edit, dashboards break loudly at import time rather than silently at
query time, and the constant list in attributes.py doubles as the catalogue of
what this library will put on a span.