feat: OpenTelemetry instrumentation for custom IO sources - #30
Merged
Conversation
ptomecek
force-pushed
the
feat/io-source-profiling
branch
8 times, most recently
from
August 31, 2026 19:47
6e1a775 to
7cae70d
Compare
ptomecek
force-pushed
the
feat/io-source-profiling
branch
4 times, most recently
from
August 31, 2026 20:35
a11c020 to
0d0e227
Compare
Contributor
Test Results1 454 tests +29 1 406 ✅ +29 40s ⏱️ -7s Results for commit b162de5. ± Comparison against base commit 0ef48ea. This pull request removes 1 and adds 30 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
ptomecek
force-pushed
the
feat/io-source-profiling
branch
7 times, most recently
from
August 31, 2026 23:09
f48f4c1 to
51bb317
Compare
ptomecek
marked this pull request as ready for review
August 31, 2026 23:11
Instrument custom Polars IO sources registered via `register_io_source_with_is_pure`. Each source execution emits one `io_source.execute[<name>]` span recording `next_elapsed_total_ms` -- the summed duration of every `next()` pull on the source iterator, the caller-observed fetch latency that a streaming engine's own per-node metrics do not capture -- plus total rows, column count, batch count, an outcome (exhausted / closed / error), and `error.type` on failure. Instrumentation is on by default and follows the OpenTelemetry convention for native library instrumentation: spans are created through the OpenTelemetry API, which is a no-op unless the application has configured an SDK. OpenTelemetry is an optional, undeclared dependency -- when the API is not installed the instrumentation no-ops, and any application that configures an SDK already provides the API transitively. It can be disabled with `OTEL_PYTHON_INSTRUMENTATION_POLARS_IO_TOOLS_ENABLED=false` (the `OTEL_PYTHON_INSTRUMENTATION_<LIBRARY>_ENABLED` opt-out convention, e.g. as elasticsearch-py uses), which skips installing the wrapper entirely for zero overhead -- letting an app keep OpenTelemetry for the rest of its code while suppressing these spans. When an SDK is configured, export is delegated to the application's span processor (use a batching processor, e.g. OpenTelemetry's BatchSpanProcessor which Logfire installs, to keep it off the Polars worker thread). Attributes are set only on recording spans and are namespaced under `polars_io_tools.`; the tracer is scoped to `polars_io_tools` with the package version. The span's identity is the source's `explain_name`, a low-cardinality label defaulting to the source function's name (a `source_generator` closure inside `scan_db` resolves to `scan_db`), or set via `explain_name`. Each public source constructor (`scan_db`, `scan_clickhouse`, `scan_delta`, `scan_datadog`, `scan_narwhals`, `scan_synthetic_regression` / `scan_synthetic_panel`, `cache`, `cache_parquet`, `debug`, `concat_named`, `filtered_join` / `filtered_join_asof`, `execute_on_ray`) also accepts a `description` argument -- a free-form per-instance label (the query, table, collection, ...) that becomes the span's `explain_detail` and lets two instances of the same source be told apart. When the installed Polars build accepts `explain_name` / `explain_detail`, the same values are forwarded to the scan, so the scan's registered identity and description match the span's. This is feature-detected against `register_io_source`'s signature (added upstream by pola-rs/polars#23978) -- a no-op on builds that lack the parameters, activating automatically once they ship. Profiling wraps the original source directly (innermost), with error-catching outermost, so timing excludes eager pre-iterator work and `close()` / `GeneratorExit` reach the real iterator (with a distinct `closed` outcome) on both early cancellation and exhaustion; a failure during the eager step still emits an `error` span. Emission is no-throw so it can never mask a source error or change results, and `set_source_span_parent()` lets a consumer nest source spans under a parent span. Adds tests (including registration-level composition tests) and extends the single-thread deadlock regression to run with and without an SDK configured. Signed-off-by: Pascal Tomecek <40371786+ptomecek@users.noreply.github.com>
ptomecek
force-pushed
the
feat/io-source-profiling
branch
from
August 31, 2026 23:14
51bb317 to
b162de5
Compare
hintse
approved these changes
Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
OpenTelemetry instrumentation for custom Polars IO sources. A streaming engine's per-node metrics measure engine-side poll/CPU time and do not capture the time a Python IO source spends blocked fetching from a backend. This recovers that, per source execution.
Each source execution registered via
register_io_source_with_is_pureemits oneio_source.execute[<name>]span recording:polars_io_tools.next_elapsed_total_ms— the summed wall duration of everynext()pull on the source iterator (caller-observed fetch latency; time the consumer spends processing between pulls is excluded);polars_io_tools.total_rows,polars_io_tools.n_columns,polars_io_tools.batch_count, and apolars_io_tools.outcomeofexhausted/closed/error.Attributes are namespaced under
polars_io_tools.; the tracer is scoped topolars_io_toolswith the package version.Naming. The span's identity is the source's
explain_name— a low-cardinality label that defaults to the source function's name (scan_db,scan_clickhouse, …; asource_generatorclosure insidescan_dbresolves toscan_db). The free-formexplain_detailis a per-instance description that distinguishes two instances of the same source (e.g. two SQL scans). Every public source constructor —scan_db,scan_clickhouse,scan_delta,scan_datadog,scan_narwhals,scan_synthetic_regression/scan_synthetic_panel,cache,cache_parquet,debug,concat_named,filtered_join/filtered_join_asof,execute_on_ray— takes adescriptionargument for this. When the installed Polars build acceptsexplain_name/explain_detail, the same values are forwarded to the scan, so the scan's registered identity and description match the span's — feature-detected againstregister_io_source's signature, dormant until the corresponding upstream Polars change lands, activating automatically once it ships.Always on, no-op without an SDK
There is no library-specific on/off switch. Following the OpenTelemetry convention for native library instrumentation, spans are created through the OpenTelemetry API only (no
logfire/vendor dependency), which is a no-op unless the application has configured an SDK — attributes are set only on recording spans. This matches how e.g.elasticsearch-pyships native OTel support: on when you configure OTel, invisible otherwise.OpenTelemetry is an optional, undeclared dependency: the code feature-detects the
opentelemetryAPI and no-ops when it is absent. Any application that configures an OpenTelemetry SDK already provides the API transitively — which is the only case where spans do anything — so nothing new is imposed on users who don't use OTel.opentelemetry-sdkis added to thedevelopextra for the test suite.Opt-out. Instrumentation is on by default; disable it with
OTEL_PYTHON_INSTRUMENTATION_POLARS_IO_TOOLS_ENABLED=false— theOTEL_PYTHON_INSTRUMENTATION_<LIBRARY>_ENABLEDconvention used by e.g.elasticsearch-py(defaulttrue). This lets an application keep OpenTelemetry for the rest of its code while suppressing these spans. When disabled the wrapper is not installed at all, so there is zero overhead (not merely no-op emission).How users use it
Configure any OTel SDK, then use the sources as normal. Each source is auto-named after its constructor; pass
description=to tell instances apart:Third-party sources registered directly can set the identity explicitly:
To nest source spans under a caller's span, publish a parent context (e.g. a query span) around the collect:
Design / safety
BatchSpanProcessor, which Logfire installs by default) to keep export off the Polars worker thread. It reuses OTel's own model (span == record,SpanProcessor/SpanExporter== sink), so there is no bespoke buffering or sink abstraction.close()/GeneratorExitpropagate to the inner iterator (releasing cursors/connections/prefetch threads), with a distinctclosedoutcome; a cleanup failure surfaces on a clean exit but never masks an active exception.perf_counter_ns()reads per batch (not per row); the span is non-recording and carries no attributes when no SDK is configured.next()including the terminalStopIterationand any failing pull; the parent context is read at emit time.Caveats (documented)
next_elapsed_totalis caller-observed pull latency, not backend CPU: a driver that prefetches on a background thread (e.g. arrow-odbc's defaultfetch_concurrently=True) does work while the generator is suspended, which this does not attribute.total_rowscounts rows yielded to the engine and can exceed a pushedn_rowsdue to batch overshoot. Withis_pure=True, one span is emitted per physical execution (a self-join over a pure source runs once).Tests
New
tests/io_sources/test_profiling.pyasserts on emitted spans via OpenTelemetry'sInMemorySpanExporter: accumulation, terminal/pre-first-batch failures, consumer-pull exclusion,close()propagation to a custom iterator (including cleanup-error surfacing without masking an active error), exactly-once emission per outcome, emission failure never masking results, empty/deferred-schema cases,is_pureself-join, parent captured at emit time, source identity resolved from the original callable, distinct low-cardinality span identities, eager-factory-failure spans, registration-level wrapper composition (eager-work exclusion + close on exhaustion), theOTEL_PYTHON_INSTRUMENTATION_POLARS_IO_TOOLS_ENABLED=falseopt-out, and feature-detected forwarding ofexplain_name/explain_detail. The single-thread Rayon deadlock regression runs both with and without an SDK configured. Fullio_sourcessuite: 1316 passed.API
Public source constructors (
scan_db,scan_clickhouse,scan_delta,scan_datadog,scan_narwhals/from_narwhals,scan_synthetic_regression/scan_synthetic_panel,cache,cache_memory,cache_parquet,debug,concat_named,filtered_join/filtered_join_asof,execute_on_ray) gain adescriptionargument.register_io_source_with_is_pure(..., explain_name=None, explain_detail=None); and inpolars_io_tools.io_sources.profiling:profile_io_source_iterator,wrap_io_source_with_profiling,source_identity,set_source_span_parent,get_source_span_parent. Env var:OTEL_PYTHON_INSTRUMENTATION_POLARS_IO_TOOLS_ENABLED(default on;falsedisables with zero overhead).