Skip to content

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. Logs go to stderr, where a collector picks them up, carrying the trace they were written under.

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 relation
    ├── interloper.destination.read            per bound upstream
    ├── 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.representation.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 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). httpx2 client spans are enabled when the httpx2 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, attempt 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
interloper.destination.rows counter destination_key; the rows a completed write carried, when the data has a length

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.

The platform adds its own instruments where the event bus does not reach:

Instrument Type Attributes
interloper.scheduler.tick gauge, unix seconds loop (cron, hook, queue, reaper, renewal); each loop's last successful tick, so time() - value is its staleness. Which replica ticked is the resource's service.instance.id, stamped by the deployment
interloper.runs.launched counter outcome (launched, failed)
interloper.runs.reaped counter reason (startup, heartbeat, timeout)
interloper.scheduler.skips counter reason (late, config, quota); due cron firings that created no run
interloper.hooks counter status (fired, failed), component_key
interloper.usage.drift gauge org_id; the usage ledger minus the runs table, zero once reconciled
interloper.agent.turns counter status (completed, failed), model
interloper.agent.tokens counter kind (input, output), model
interloper.agent.requests histogram model; model requests one turn took
interloper.agent.compactions counter turns whose older history was summarised

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.

Logs

The commands write their log records to stderr; they are not exported over OTLP. Shipping them is the job of whatever collects the container's output, typically an OpenTelemetry Collector reading the node's pod logs with the filelog receiver.

With log.format set to json (INTERLOPER_LOG_FORMAT=json), each record is one JSON object per line:

{"timestamp": "2026-10-13T12:00:00.123456+00:00", "severity": "INFO", "logger": "interloper_scheduler.queue", "message": "Launching run 537b6608-...", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7"}

trace_id and span_id are present when the record is written inside a span, so a collector can lift them onto the log record and link each line to its trace. A record with an exception carries the formatted traceback in exception. The default, text, is the human-readable line for a terminal.

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 spans, attributes and instruments are listed below.

Spans

Span Wraps Notes
interloper.runner.run Runner.run() Root of a run. Attributes: run metadata, interloper.runner.type, interloper.partition. Status error when the result is failed.
interloper.operation.execute Operation.execute() One per operation.
interloper.asset.resolve_resource resource lookup One per resource relation; adds interloper.resource.name.
interloper.destination.read Destination.read() One per upstream dependency; adds interloper.destination.key, interloper.upstream.key.
interloper.asset.data data()
interloper.normalizer.normalize Normalizer.normalize() Only when a normalizer is configured.
interloper.asset.conform the conform step
interloper.asset.infer_schema schema inference Only under AUTO without a declared schema.
interloper.representation.reconcile Representation.reconcile() Only with a declared schema under AUTO or RECONCILE.
interloper.destination.write Destination.write() One per destination; adds interloper.destination.key.
interloper.dag.materialize DAG.materialize_async() Root when a DAG is driven directly. Attribute interloper.dag.operation_count.
interloper.dag_spec.reconstruct DAGSpec.reconstruct() Attribute interloper.dag.spec_items.

Attributes

Attribute Source metadata key
interloper.run.id run_id
interloper.backfill.id backfill_id
interloper.component.id component_id
interloper.component.kind component_kind
interloper.component.key component_key
interloper.component.qualified_key qualified_key
interloper.parent.id parent_id
interloper.partition window
interloper.destination.key destination_key
interloper.upstream.key set directly on read spans
interloper.resource.name set directly on resolve spans
interloper.runner.type set directly on the run span
interloper.dag.operation_count, interloper.dag.spec_items set directly on DAG spans
interloper.org.id, interloper.target.id, interloper.target.kind, interloper.target.key, interloper.target.name org_id, target_* when the platform supplies them
interloper.launcher.type set by platform launchers

interloper.telemetry.attributes.from_metadata(metadata) performs the mapping, dropping None values and stringifying the rest.

Metrics

All instruments are recorded by OtelMetricsHandler, an event-bus subscriber, and deduped on event id.

Instrument Kind Unit Attributes
interloper.runs counter {run} status (completed, failed); org_id, target_kind, target_key when present
interloper.run.duration histogram s same
interloper.operations counter {execution} status (completed, failed, canceled), component_key
interloper.operation.duration histogram s same
interloper.destination.io counter {operation} operation (read, write), status (completed, failed), destination_key

Durations are measured between the start and terminal events' timestamps. Counters and histograms export with delta temporality; up-down counters and gauges stay cumulative.

Propagation helpers

Function Purpose
inject_metadata(metadata) Write the current span context into a metadata dict (traceparent).
extract_metadata(metadata) Read a parent context back from it.
traceparent_env() {"TRACEPARENT": ..., "TRACESTATE": ...} for the current span.
child_process_env() INTERLOPER_OTEL_* plus the trace context, with INTERLOPER_OTEL_SERVICE_NAME=interloper-run.
context_from_env() A parent context from TRACEPARENT / TRACESTATE.

Setup functions

Function Purpose
init_telemetry(settings) Install providers and exporters. Idempotent; returns whether the SDK is active.
shutdown_telemetry() Flush and shut the providers down.
force_flush() Flush without shutting down.
instrument_fastapi(app) Add request spans to a FastAPI app when telemetry is active.
tracer(), meter() The framework's cached tracer and meter.

init_telemetry sets OTEL_SEMCONV_STABILITY_OPT_IN=http unless already set, so HTTP spans use the stable semantic conventions. Contrib instrumentors for httpx2 and SQLAlchemy are activated when installed.