Summary
@track(create_duplicate_root_span=False) and @track(source=...) have no effect when they are applied to a generator function (sync or async). Both are honoured for plain and async def functions.
The cause is one call site: BaseTrackedGenerator._ensure_span_and_trace_created invokes the shared span-creation helper with only two positional arguments, so should_create_duplicate_root_span and source fall back to their defaults (True / None), and the should_process_span_data value it gets back — which is the flag that means "do not keep this duplicate root span" — is bound to result and never read.
Measured
origin/main at 6e327d4ea, CPython 3.11.15, opik installed editable, run through the repo's own unit-test harness (fake_backend, so the assertions are on what would be sent to the backend).
Duplicate root span — two tests mirroring tests/unit/decorator/test_tracker_root_span.py, but flagging the generator itself:
@tracker.track(create_duplicate_root_span=False)
def gen(x):
yield "v1"
yield "v2"
list(gen("in"))
E DeepAssert detailed comparison:
E Item root.spans[0] (SpanModel(source='sdk', name='gen', input={'x': 'in'}, output={'o…
E AssertionError: assert [SpanModel(id…)] == []
The trace is gen and its spans should be empty; the duplicate root span named gen is there anyway.
@tracker.track(create_duplicate_root_span=False)
async def agen(x):
await asyncio.sleep(0)
yield "a1"
async for _ in agen("in"): pass
E AssertionError: assert [SpanModel(id…)] == []
E + where 1 = len([SpanModel(id='01a0b56d-16dd-7cfe-…', …)])
Same for the async generator.
Dropped source — printing what the backend receives:
PROBE[0] name=gen trace_source=sdk spans=[('gen', 'sdk')] # @track(source="experiment")
PROBE[1] name=plain trace_source=experiment spans=[('plain', 'experiment')] # same option, plain function
So a tracked generator's trace and root span are both recorded with source sdk no matter what was requested, and the suppression option that exists precisely to avoid the extra root-span row still produces it for exactly the functions that stream.
Why the repo clearly intends both to work
opik.track documents them: create_duplicate_root_span: Whether to create a root span duplicating the root trace data. and source (decorator/base_track_decorator.py:72,93), and both are carried in TrackOptions (arguments_helpers.py:76-77) — which the generator wrapper already holds as self._track_options and already uses for capture_output and generations_aggregator.
span_creation_handler.create_span_respecting_context is the single shared entry point and declares should_create_duplicate_root_span: bool = True and source: Optional[TraceSource] = None (span_creation_handler.py:42,45); in the root branch it returns should_process_span_data=should_create_duplicate_root_span (:182).
- The other two call sites forward them.
add_start_candidates (base_track_decorator.py:755-761) passes should_create_duplicate_root_span=, preset_trace_id= and source= and then branches on span_creation_result.should_process_span_data — only adding the span to the context and logging it when the flag says so, otherwise calling _show_root_span_not_created_warning_if_needed. context_manager/span_context_manager.py:63-69 passes it explicitly too.
- Six existing tests in
tests/unit/decorator/test_tracker_root_span.py assert the suppression for the plain paths (:27, :68, :124, :196, :265, and the parametrised type case). The one named for this case — test_track__disable_root_span__async_generator__no_root_span_created (:306) — flags async_generator_user, which is an ordinary async def that consumes an unflagged @tracker.track async generator. So the name reads as coverage for this path while the generator function itself is never the flagged one; the two probes above are the first time that shape is exercised.
Why it is not a one-line forwarding fix
I checked this before proposing anything, because the obvious patch (pass the two options, then drop the span when should_process_span_data is false) does not survive contact with the generator lifecycle:
SyncTrackedGenerator.__next__ / AsyncTrackedGenerator.__anext__ assert self._created_span_data is not None, and the "already created" guard is literally if self._created_span_data is not None: return. With the span suppressed, the guard never trips and every chunk would start a new trace, so the wrapper needs a separate "creation happened" signal.
context_storage.temporary_context(span_data, trace_data) requires a span (span_data.project_name, then add_span_data(span_data) at context_storage.py:294-313), so while the generator body runs there is no primitive for "put the trace in the context but no span". In the plain path that is what makes nested calls attach to the trace with parent_span_id=None.
BaseTrackDecorator.__after_call_unsafe selects the generator branch with if generators_span_to_end is None: (base_track_decorator.py:522). A generator that has a trace but no span is therefore unrepresentable and would fall into pop_end_candidates() and consume whatever the caller's context holds.
So the fix needs a trace-only form for a suppressed generator, touching the shared finalize condition. That is a decision about the contract of that condition, not a mechanical bug fix, and it sits in the code path every tracked generator and stream goes through — I would rather have it directed than guessed at in a drive-by PR.
Questions for maintainers
- Should a span-suppressed generator nest its children under the trace the same way the plain path does, and should
_show_root_span_not_created_warning_if_needed also fire for generators?
source is forwarded to the root span as a bare source=source (span_creation_handler.py:171-176) while the trace gets source if source is not None else "sdk" — is a None span source intended to stay None, or should the span mirror the trace default?
- If both answers are "mirror the plain path", I am happy to implement it here with the two probe cases above as regression tests, plus the
source assertion.
Scope
Nothing else about generator tracing is claimed here: aggregation of yielded values, preset_trace_id / opik_args on generators, and stream handling were not investigated, and the plain-function behaviour is correct as far as these tests show.
Summary
@track(create_duplicate_root_span=False)and@track(source=...)have no effect when they are applied to a generator function (sync or async). Both are honoured for plain andasync deffunctions.The cause is one call site:
BaseTrackedGenerator._ensure_span_and_trace_createdinvokes the shared span-creation helper with only two positional arguments, soshould_create_duplicate_root_spanandsourcefall back to their defaults (True/None), and theshould_process_span_datavalue it gets back — which is the flag that means "do not keep this duplicate root span" — is bound toresultand never read.Measured
origin/mainat6e327d4ea, CPython 3.11.15,opikinstalled editable, run through the repo's own unit-test harness (fake_backend, so the assertions are on what would be sent to the backend).Duplicate root span — two tests mirroring
tests/unit/decorator/test_tracker_root_span.py, but flagging the generator itself:The trace is
genand itsspansshould be empty; the duplicate root span namedgenis there anyway.Same for the async generator.
Dropped
source— printing what the backend receives:So a tracked generator's trace and root span are both recorded with source
sdkno matter what was requested, and the suppression option that exists precisely to avoid the extra root-span row still produces it for exactly the functions that stream.Why the repo clearly intends both to work
opik.trackdocuments them:create_duplicate_root_span: Whether to create a root span duplicating the root trace data.andsource(decorator/base_track_decorator.py:72,93), and both are carried inTrackOptions(arguments_helpers.py:76-77) — which the generator wrapper already holds asself._track_optionsand already uses forcapture_outputandgenerations_aggregator.span_creation_handler.create_span_respecting_contextis the single shared entry point and declaresshould_create_duplicate_root_span: bool = Trueandsource: Optional[TraceSource] = None(span_creation_handler.py:42,45); in the root branch it returnsshould_process_span_data=should_create_duplicate_root_span(:182).add_start_candidates(base_track_decorator.py:755-761) passesshould_create_duplicate_root_span=,preset_trace_id=andsource=and then branches onspan_creation_result.should_process_span_data— only adding the span to the context and logging it when the flag says so, otherwise calling_show_root_span_not_created_warning_if_needed.context_manager/span_context_manager.py:63-69passes it explicitly too.tests/unit/decorator/test_tracker_root_span.pyassert the suppression for the plain paths (:27,:68,:124,:196,:265, and the parametrisedtypecase). The one named for this case —test_track__disable_root_span__async_generator__no_root_span_created(:306) — flagsasync_generator_user, which is an ordinaryasync defthat consumes an unflagged@tracker.trackasync generator. So the name reads as coverage for this path while the generator function itself is never the flagged one; the two probes above are the first time that shape is exercised.Why it is not a one-line forwarding fix
I checked this before proposing anything, because the obvious patch (pass the two options, then drop the span when
should_process_span_datais false) does not survive contact with the generator lifecycle:SyncTrackedGenerator.__next__/AsyncTrackedGenerator.__anext__assertself._created_span_data is not None, and the "already created" guard is literallyif self._created_span_data is not None: return. With the span suppressed, the guard never trips and every chunk would start a new trace, so the wrapper needs a separate "creation happened" signal.context_storage.temporary_context(span_data, trace_data)requires a span (span_data.project_name, thenadd_span_data(span_data)atcontext_storage.py:294-313), so while the generator body runs there is no primitive for "put the trace in the context but no span". In the plain path that is what makes nested calls attach to the trace withparent_span_id=None.BaseTrackDecorator.__after_call_unsafeselects the generator branch withif generators_span_to_end is None:(base_track_decorator.py:522). A generator that has a trace but no span is therefore unrepresentable and would fall intopop_end_candidates()and consume whatever the caller's context holds.So the fix needs a trace-only form for a suppressed generator, touching the shared finalize condition. That is a decision about the contract of that condition, not a mechanical bug fix, and it sits in the code path every tracked generator and stream goes through — I would rather have it directed than guessed at in a drive-by PR.
Questions for maintainers
_show_root_span_not_created_warning_if_neededalso fire for generators?sourceis forwarded to the root span as a baresource=source(span_creation_handler.py:171-176) while the trace getssource if source is not None else "sdk"— is aNonespan source intended to stayNone, or should the span mirror the trace default?sourceassertion.Scope
Nothing else about generator tracing is claimed here: aggregation of yielded values,
preset_trace_id/opik_argson generators, and stream handling were not investigated, and the plain-function behaviour is correct as far as these tests show.