Telemetry¶
Interloper exports OpenTelemetry traces and metrics over OTLP: one trace per run, from
the runner through every operation, data() call and destination read or write, across
process boundaries; plus counters and duration histograms derived from the event bus.
Telemetry is off by default and costs nothing when disabled: the core depends only on the no-op
opentelemetry-api. The SDK and exporters ship in the otel extra.
Enabling¶
pip install 'interloper-core[otel]'
export INTERLOPER_OTEL_ENABLED=true
export INTERLOPER_OTEL_ENDPOINT=http://localhost:4317
interloper run my_package.sources.Shop
The CLI initializes telemetry for every command. Library code (a script, a notebook) does it explicitly:
import interloper as il
from interloper.settings import TelemetrySettings
from interloper.telemetry import init_telemetry, shutdown_telemetry
init_telemetry(TelemetrySettings(enabled=True, endpoint="http://localhost:4317"))
il.run(il.AsyncRunner().run(dag))
shutdown_telemetry() # flush before exit
init_telemetry is idempotent and a no-op when disabled. When enabled without the extra
installed it logs a warning and leaves the no-op providers in place; telemetry never takes the
data plane down. force_flush() flushes without shutting down, for reused worker processes.
Settings¶
The otel block of interloper.yaml, or INTERLOPER_OTEL_* variables:
| Setting | Default | Meaning |
|---|---|---|
enabled |
false |
Master switch. The standard OTEL_* variables never activate the SDK on their own. |
endpoint |
empty | OTLP endpoint. Empty falls through to OTEL_EXPORTER_OTLP_ENDPOINT. |
protocol |
grpc |
grpc or http/protobuf. |
headers |
empty | Exporter headers as key=value,key2=value2. Treat as a secret. |
service_name |
interloper |
The service.name resource attribute. |
traces, metrics |
true |
Signal toggles. |
sample_ratio |
1.0 |
Parent-based head sampling ratio. |
metric_export_interval |
60 |
Seconds between metric exports. |
Interloper settings win over the SDK's OTEL_* variables; anything left empty can still be
supplied through them.
Traces¶
Spans are named interloper.<class>.<method> after the call they wrap:
interloper.runner.run Runner.run
└── interloper.operation.execute Operation.execute
├── interloper.asset.resolve_resource per resource slot
├── interloper.destination.read per upstream dependency
├── interloper.asset.data data()
├── interloper.normalizer.normalize only when a normalizer is configured
├── interloper.asset.conform
│ ├── interloper.asset.infer_schema AUTO without a declared schema
│ └── interloper.conformer.reconcile with a declared schema
└── interloper.destination.write per destination
interloper.dag.materialize wraps dag.materialize(), and interloper.dag_spec.reconstruct
measures the deserialization paid by process and container workers. Resource resolution is
lookup and instantiation only; a client built lazily on a connection costs under
interloper.asset.data.
Spans carry interloper.* attributes: run and backfill ids, component id, kind, key and
qualified key, source id, partition, destination key, upstream key, resource name, runner type.
A failed operation sets its span status to error, and so does a run that swallowed failures
into a failed result.
Trace context propagates automatically: the run span's context rides metadata["traceparent"]
into every event and into MultiProcessRunner workers, and TRACEPARENT / TRACESTATE
environment variables carry it into spawned processes. child_process_env() builds the
environment a child needs (trace context plus the INTERLOPER_OTEL_* configuration, with the
service name reset to interloper-run). httpx client spans are enabled when the httpx
instrumentation is installed, so REST-based sources get egress spans for free.
Metrics¶
| Instrument | Type | Attributes |
|---|---|---|
interloper.runs |
counter | status, plus org_id, target_kind, target_key when the platform supplies them |
interloper.run.duration |
histogram, seconds | same |
interloper.operations |
counter | status, component_key |
interloper.operation.duration |
histogram, seconds | status, component_key |
interloper.destination.io |
counter | operation (read, write), status, destination_key |
Metrics are computed by a subscriber on the event bus, so they cost nothing on the
execution path, and they dedupe on event id so re-emitted child events count once. Attributes
stay low-cardinality by design: ids and partitions never become metric attributes. In a child
container (INTERLOPER_EVENTS_TO_STDERR=true) the metrics handler is not installed; the host
that re-emits the events is authoritative.
Delta temporality¶
Counters and histograms are exported as deltas, not running totals. Runs are short-lived
processes: a cumulative point from a process that exports once has no earlier point to be
differenced against, and the next process restarts at zero. A delta point is self-contained. The
collector must therefore accumulate: an OpenTelemetry Collector with the deltatocumulative
processor in front of a Prometheus exporter that is scraped, with OpenMetrics enabled so
counter start timestamps survive, and metric_expiration raised well above the default so idle
counters keep publishing. The accumulated totals live in the collector; restarting it resets
them. A worked collector and Grafana setup is in the repository under examples/telemetry.
Custom instrumentation¶
tracer() and meter() from interloper.telemetry return the framework's tracer and meter,
no-op until the SDK is initialized:
from interloper.telemetry import tracer
with tracer().start_as_current_span("shop.fetch_report", attributes={"shop.account": account_id}):
...
interloper.telemetry.attributes.from_metadata(metadata) maps event-style metadata onto the
interloper.* attribute names. The full list of spans, attributes and instruments is in
Spans and metrics.