Repository navigation
fix(fi_instrumentation): keep register()'s exporter when another span processor is added (TH-8727) - #281
Merged
Conversation
… processor is added (TH-8727) register() marked its own processor as a default (otel.py:244, `tracer_provider._default_processor = True`). The TracerProvider add_span_processor override shuts a default down and removes it, so the next add_span_processor() call on the provider register() returns (a console exporter, a second backend, LiveKit's privacy filter) dropped the Future AGI exporter, and traces silently stopped reaching Future AGI. Upstream OpenTelemetry's add_span_processor() only adds. register() now removes the provider's construction default itself and adds its processor as an ordinary one, so later processors run next to it. A TracerProvider() constructed directly is unchanged: its first add_span_processor() call still replaces the default SimpleSpanProcessor, and now logs that at INFO. register(span_exporter=...) stays the way to export somewhere else instead. The livekit test that pinned the old register() behaviour now expects the behaviour of the installed fi-instrumentation-otel: traceAI-livekit allows 1.1.0, so its TH-8665 workaround in enable_*() stays. Release: fi-instrumentation-otel 1.1.1 (version, CHANGELOG, README). Not published. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The fix is deleting `tracer_provider._default_processor = True` from register(). The private _remove_default_processor() helper and the INFO line logged when a bare TracerProvider() replaces its default went beyond that, so they are removed, with the test of the log line. A bare TracerProvider() behaves exactly as on main again. The livekit test that follows the installed fi-instrumentation-otel now reads the mark register() leaves on the provider instead of comparing version numbers, so it stays right whatever version the fix ships in. The README and the livekit comments no longer name the unreleased version; only CHANGELOG.md and pyproject.toml do. The CHANGELOG entry now says a register(span_exporter=...) exporter was dropped the same way, and is kept now. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ovider already exports to (TH-8727) configure_strands_tracing() always called StrandsTelemetry's setup_otlp_exporter(), which adds an upstream OTLP exporter to the provider it is given. With README Option 1 (register(), then configure_strands_tracing(tracer_provider=...)) and OTEL_EXPORTER_OTLP_* pointing at Future AGI, as the README's environment section suggests, that exporter and register()'s Future AGI exporter both sent every span to Future AGI once fi-instrumentation-otel stopped dropping register()'s exporter on add_span_processor(). Before that fix, the drop hid the duplicate. The instrumentor now resolves the endpoint Strands' exporter would use (OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, else OTEL_EXPORTER_OTLP_ENDPOINT, else localhost:4318) and skips it, with an INFO line, when a processor on the provider already exports to the same host and port. Any other collector still gets Strands' exporter; the console exporter and the meter are unchanged. Tests: unit tests for the decision, plus README Option 1 run for real (register(), the real exporters, collectors on 127.0.0.1) in a fresh interpreter, because conftest.py mocks opentelemetry for the session. Release: traceai-strands 0.1.1 (version, __version__, a new CHANGELOG). The fi-instrumentation-otel 1.1.1 entry names the case. Not published. Co-Authored-By: Claude Opus 5.5 <[email protected]>
… on a provider that keeps its exporter (TH-8727) 43e25de skipped Strands' OTLP exporter whenever a processor on the provider exported to the same host and port. Review found two ways that loses spans: - On fi-instrumentation-otel 1.1.0, register() still marks its exporter as a default the next add_span_processor() call replaces. With OTEL_EXPORTER_OTLP_* at Future AGI, the skip left that marked exporter as the only one, and the next processor (enable_console_exporter=True, or one the application adds) replaced it: Future AGI got nothing. traceai-strands 0.1.0 never hit this, because Strands' own exporter consumed the mark. - When register() ran without FI keys and the keys reached Strands' exporter through otlp_headers or OTEL_EXPORTER_OTLP_HEADERS, the skip removed the only exporter Future AGI would accept. The exporter is now skipped only when all of these hold: - the provider would keep its exporter on the next add_span_processor() call: no `_default_processor` mark on that provider. This is the flag the core's add_span_processor() override reads, checked on the provider being configured rather than through a version number. On 1.1.0 Strands' exporter is then added exactly as in 0.1.0. A bare fi_instrumentation TracerProvider() whose construction default is still pending replacement is covered by the same check. - a processor on the provider exports to the same traces URL: scheme, userinfo, host, port (default filled in), path (repeated and trailing slashes dropped) and query, instead of host and port only; - that exporter already sends every header Strands' exporter would send, with the same value, and no client certificate or credential provider is set for Strands' exporter. Strands' endpoint and headers come from an upstream OTLPSpanExporter built with no arguments, as Strands builds it, read back and shut down. That follows the installed exporter release: 1.45 merges environment headers into passed ones and moved them to `_client`, and treats an empty OTEL_EXPORTER_OTLP_TRACES_ENDPOINT differently from 1.29-1.39. A floor of fi-instrumentation-otel>=1.1.1 was not added: it would make traceai-strands 0.1.1 uninstallable until the core is published (and fail this repo's CI install from PyPI), tie it to a version number that changes if #272 ships first, and still not protect an environment resolved without it (--no-deps, a lock pinned to 1.1.0). Tests: unit tests for each rule (URL parts and spellings, keys only on one side, other keys, x-project-name, unreadable headers, mTLS and credential-provider variables, both header layouts, the marked provider). README Option 1 run for real, now also with the console exporter, a processor added afterwards, keys only on Strands' exporter and a base endpoint, on the repo core and on 1.1.0. README Option 1 and the CHANGELOG say exactly when the exporter is added. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…r() behaviour (TH-8727) test_add_span_processor_after_register_alone_follows_the_mark_enable_clears read the mark from the provider and asserted whichever outcome matched, so a mark reintroduced in register() passed it. It is now test_add_span_processor_after_register_alone_matches_the_installed_core: the expected state comes from the installed release (1.1.0 and earlier set the mark, later releases and this repo's source do not), and the test asserts the mark itself before checking which processors remain. With the deleted line put back in this repo's otel.py, the new test fails and the old one passes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ged (TH-8727) The add_span_processor() and register(span_exporter=...) behaviour changes move out of the Fixed entry into a "### Changed" section, with the second-exporter upgrade note and the unchanged bare TracerProvider(). The traceai-strands note now says when 0.1.1 skips its exporter instead of implying the upgrade alone always avoids duplicates. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Use the actual OTLP HTTP credential-provider environment variable names, not the underscored SDK constant identifiers. Require both exporters to have the same header names and values, so an extra routing header on either side keeps Strands' exporter. Verify both cases and the provider-only-auth case with real exports. Correct the OTel header-storage compatibility note and fix the Option 2 example: otlp_endpoint is a base URL, so including /v1/traces duplicated the signal path. Update the PR notes to mark 79be724's livekit-test description superseded by 36d302f's version-based expectation. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…TH-8727) Restore a case where both exporters send the same header names with different values (other keys): Strands' exporter must still be added. A check that compared header names alone passed every test after b1bbf5a. The class docstring and the core 1.1.1 CHANGELOG now describe the exact rule: same traces URL and exactly the same header names and values. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Nikhil Pareek <[email protected]>
Dev to main, 21st Oct, 2026
Conflict in python/frameworks/livekit/tests/test_integration.py imports only: main (TH-8730, #282) dropped `import signal`; this branch's version-based livekit test needs `importlib.metadata` and `re`. Kept those two, dropped `signal` (unused after the merge). Checked on the merged tree (Python 3.11, CI's pytest pins): livekit 82 passed with livekit-agents 1.8.8; strands 102 passed; fi_instrumentation 205 passed, 3 failed - the same 3 timeout-introspection tests fail on main's suite with OTel 1.45 (CI locks 1.34.1). Co-Authored-By: Claude Opus 5.5 <[email protected]>
The repo now releases dev -> main (#283), and #271/#272 target dev, so this PR moves to dev too. Changing the base does not start the pull_request workflows; this empty commit does. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Brings in #272 (fi-instrumentation-otel 1.2.0, error-detail hiding) and #203. Conflicts were only the version lines: this fix becomes 1.2.1, as planned in the PR notes for the case where #272 merges first. pyproject.toml, CHANGELOG.md and fi_instrumentation/instrumentation/version.py all read 1.2.1; the 1.2.1 entry sits above 1.2.0. 1.2.0 was never published (PyPI latest is 1.1.0), so the READMEs and the livekit test keep naming 1.1.0 as the last release with the mark. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…he examples Strands creates its spans from OpenTelemetry's global tracer provider (strands/telemetry/tracer.py uses trace_api.get_tracer_provider()). configure_strands_tracing(tracer_provider=...) only attaches exporters to the provider it is given and never makes it global, and register() defaults to set_global_tracer_provider=False. So README Option 1 and examples/*.py, run verbatim, sent 0 spans to Future AGI with no error: the agent's spans went to the no-op default provider. A real Strands agent on gpt-4.1-mini reproduced it on both this branch and PyPI 1.1.0 / traceai-strands 0.1.0. Add the flag, with a comment saying why, to the README Option 1 snippet and all four examples (basic_agent, agent_with_tools, callback_handler, mcp_agent), explain it under Option 1, and note it in the 0.1.1 CHANGELOG. Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 does this PR do?
The provider
register()returns now keeps its Future AGI exporter when another span processor is added. Before, the firstadd_span_processor()call shut the exporter down and removed it. Traces then stopped reaching Future AGI, and nothing reported an error.register()no longer runstracer_provider._default_processor = True(python/fi_instrumentation/otel.py:244onmain). A processor added later runs next to the Future AGI exporter, asadd_span_processor()does in upstream OpenTelemetry. The otherotel.pychanges are docstrings and a comment.configure_strands_tracing()now skips Strands' own OTLP exporter only when the provider already sends to the same traces URL with exactly the same headers (same names and values, none missing on either side), and keeps its exporter when another processor is added. On fi-instrumentation-otel 1.1.0, traceai-strands 0.1.1 adds the exporter exactly as 0.1.0 does.Why?
Linear: TH-8727. #273 called this the underlying
register()trap and tracked it as TH-8672.Root cause.
register()added its processor, then marked it as a default:otel.py:244onmain.TracerProvider.add_span_processoroverride (otel.py:330-341onmain) shuts down every processor and removes it.add_span_processor()call silently dropped the exporterregister()built: the Future AGI exporter, or the custom one passed asregister(span_exporter=...).Who it hit.
add_span_processor, so traceAI-livekit sent no traces at all. fix(livekit): keep the Future AGI exporter when LiveKit adds its privacy filter (TH-8665) #273 fixed that inside traceAI-livekit, by clearing the mark inenable_*_attribute_mapping().register(), thenconfigure_strands_tracing(tracer_provider=...)): Strands adds its OTLP exporter throughadd_span_processor, which dropped the Future AGI exporter. Spans then went only whereOTEL_EXPORTER_OTLP_*pointed, or tolocalhost:4318when it was unset.register()returns, including one who passedspan_exporter=.Repro. One span, sent to a local OTLP/HTTP sink on 127.0.0.1.
register(project_type=OBSERVE, verbose=False), then optionallyadd_span_processor(SimpleSpanProcessor(InMemorySpanExporter())):batchadd_span_processorotel.pyis identical tomain(17e573e): Future AGI exports / in-memory spanstraceai-strands, README Option 1, 3 spans, with real strands-agents 1.61.1. Both collectors are on 127.0.0.1 and record the
X-Api-Keyeach request carried. Each cell is the number of spans Future AGI got with the keys (the copies it accepts); a span counted twice is a duplicate. "The fix" is this repo's fi-instrumentation-otel; "1.1.0" is PyPI.OTEL_EXPORTER_OTLP_*)main: 1.1.0 + strands 0.1.0register()'s exporterenable_console_exporter=Trueotlp_headers=(register()had none)The 43e25de columns are the previous head of this PR. Its host-and-port check skipped Strands' exporter on 1.1.0 too, where
register()'s exporter is still marked, so the next processor replaced it and Future AGI got nothing. It also skipped the only exporter that carried the keys. The last row on 1.1.0 is the core bug itself; traceai-strands cannot fix it there.The change
otel.py):_default_processor = Trueline inregister()is deleted.register()'s ownadd_span_processor()call still replaces the default the provider adds on construction; now the mark just stays cleared afterwards.register(),TracerProviderandadd_span_processorsay what happens now. A comment atotel.py:246-247explains the call.register(verbose=True)no longer prints "add_span_processorwill overwrite this default", which is now untrue. Only the mark controlled that line.TracerProvider()behaves exactly as onmain.traceai_strands/_instrumentor.py):setup_otlp_exporter(),_strands_otlp_exporter_target()(_instrumentor.py:95-121) builds the upstream OTLP/HTTP span exporter the way Strands'setup_otlp_exporter()does, with no arguments. It reads back the traces URL and headers that exporter resolved fromOTEL_EXPORTER_OTLP_*, then shuts it down. The implementation checks_headerson the exporter (the location inopentelemetry-exporter-otlp-proto-http1.29-1.44, verified against 1.29 and 1.39) and_client._headersfrom 1.45. In 1.29-1.44, headers passed to the exporter replace the environment's, which are used only when none are passed, and an explicitly emptyOTEL_EXPORTER_OTLP_TRACES_ENDPOINTresolves to an empty URL; from 1.45, environment and passed headers are merged, and an empty traces endpoint falls back to the base endpoint._strands_otlp_exporter_duplicates()(_instrumentor.py:141-173) skips the exporter only when all of these hold. The skip is logged at INFO (_instrumentor.py:276-285)._default_processormark, so it keeps its exporter on the nextadd_span_processor()call. This is the flag the core's override reads, checked on the provider being configured. fi-instrumentation-otel 1.1.0 leaves it set on the providerregister()returns; a barefi_instrumentationTracerProvider()has it until its firstadd_span_processor()call.x-project-nameheader thatproject_name=adds. Client certificate, client key or credential-provider environment variables set for Strands' exporter keep it too. OpenTelemetry's SDK constant names for the two credential-provider variables start with_; the actual environment variable names checked here do not.main. The console exporter and the meter are unchanged.localhost:4318when unset), except for that one duplicate case. It also covers what happens on 1.1.0. The environment section says the variables only set where Strands' own exporter sends. The Option 2 example now givesotlp_endpointa base URL: passing a URL already ending in/v1/tracesused to create/v1/traces/v1/tracesbecause Strands appends the signal path.__version__, and a new CHANGELOG.enable_*workaround stays, because the package allows fi-instrumentation-otel 1.1.0, which still sets the mark. It does nothing on the fixed release. Only its comment changes, plus one test, described below. No livekit release is needed.Why a behaviour check in traceai-strands, not a
fi-instrumentation-otel>=1.1.1floor:pip --no-deps, a lock pinned to 1.1.0, or a vendored core.Behaviour change and compatibility
Changed
add_span_processor()on the providerregister()returns adds the processor next toregister()'s exporter instead of replacing it. Code that relied on it to swap outregister()'s exporter now exports to both. To export somewhere else instead, passregister(span_exporter=...).register(span_exporter=...)followed byadd_span_processor()keeps the custom exporter, whichmaindropped, so spans go to both.The CHANGELOG now has these under
### Changed(python/CHANGELOG.md, 1.1.1). The### Fixedentry keeps only the bug.Upgrade note
A user on traceai-strands 0.1.0 whose
OTEL_EXPORTER_OTLP_*points at Future AGI can get duplicate requests after upgrading only fi-instrumentation-otel. traceai-strands 0.1.1 skips its exporter only when the provider and Strands exporters have exactly the same URL and header set; if the headers differ, it keeps Strands' exporter. The fi-instrumentation-otel 1.1.1 CHANGELOG names the upgrade case. With extra headers on Strands' exporter (for exampleAuthorization=Bearer ..., as in the strands README's environment example, orproject_name=), 0.1.1 keeps it, so a backend accepting both copies still gets two.Unchanged
TracerProvider()constructed directly: its docstrings document this, andtests/test_batch_span_processor_tuning.py:335-336and:356-357rely on it (a bareTracerProvider(), then its firstadd_span_processor()call).register()on its own, and everyregister()argument.localhost:4318with nothing listening, which logs export warnings as onmain.register()'s. Option 1 therefore still loses Future AGI spans there unless Strands' exporter points at Future AGI with the keys. That is the core bug, and the strands README says to upgrade fi-instrumentation-otel.Known limits
register(transport=Transport.GRPC)sends togrpc.futureagi.com, while Strands' exporter is HTTP. The URLs differ, so a duplicate there is not detected.OTEL_EXPORTER_OTLP_ENDPOINT=https://api.futureagi.comresolves tohttps://api.futureagi.com/v1/traces, notregister()'s/tracer/v1/traces, so Strands' exporter is kept for it. The README shows the URL that is recognised.register()'s exporter uses the same one: the probe cannot compare them.How was it tested?
Python 3.11, as in CI, on fresh environments built from each package's
[dev]setup, plus this repo's core where noted. The Poetry lock used by CI pins OpenTelemetry 1.34.1. Collector URLs point at 127.0.0.1.python/tests/test_register_keeps_exporter.py, 9 tests. They send through the real Future AGI exporters to an OTLP/HTTP and an OTLP/gRPC collector on 127.0.0.1, then check the span names each one received.register()+add_span_processor(in-memory): both the collector and the in-memory exporter get the span. Run over HTTP and gRPC, each withbatch=Trueandbatch=False.register(): all three get the span.register(span_exporter=custom)+ another processor: the custom exporter and the other processor get it, and the collector gets nothing. Run withbatch=Trueandbatch=False.register(verbose=True)prints the tracing details without "will overwrite this default".TracerProvider(): the first call replaces the default, the next one adds, and nothing reaches the collector.main'sotel.py, 8 of the 9 fail. Only the bare-provider test passes, which pins that this behaviour is unchanged.mainplus 9 new. The same 208 pass on 1.29.0. On 1.45.1, 205 pass and three existing timeout-introspection tests fail because upstreamHTTPSpanExporterno longer has_timeout; these same three failures reproduce with PyPI fi-instrumentation-otel 1.1.0.otlp_endpoint=, URL normalization, different URL components, default endpoint, no processors, identical headers, a header extra on either exporter, keys only on either exporter, the same header names with other values (other keys; restored in cb66f71),x-project-name, unreadable headers, client certificate and both actual credential-provider environment variables, both OTel header-storage layouts, probe shutdown, and a marked provider with and without the console exporter.conftest.pymocks opentelemetry for the session. They useregister()and real exporters: exact same Future AGI URL and keys (no duplicate), same URL but keys only on the provider (Strands exporter stays), a base endpoint, the console exporter, a processor added afterwards, keys only inotlp_headers=, and a second collector. CI has no strands-agents (it is not a dev dependency), so there the scenario uses a copy ofStrandsTelemetry.setup_otlp_exporter()andsetup_console_exporter(). With strands-agents installed, it uses the real one.main's_instrumentor.pyand 43e25de's implementation reproduced the original duplicate, missing-key and dropped-exporter cases; the new tests exercise those regressions, including extra headers on either side.enable_*workaround deleted, all 58 still pass on this repo's core, so the core fix alone covers TH-8665. 8 fail on 1.1.0, which is why the workaround stays;test_add_span_processor_after_register_alone_matches_the_installed_corereplaces..._follows_the_mark_enable_clears. The expected mark is version-based: 1.1.0 and earlier set it, later releases do not. The test asserts that expected mark, then checks which processors remain. With the deleted line put back in this repo'sotel.py, it fails.register()behaviour is the same: openai (7), anthropic (8) and bedrock (21 passed, plus the knowntest_bedrock_image_attributesfailure) gave the same results on 1.1.0 and on the fixed core. CI runs all 49 packages against this repo's core, becausepython/fi_instrumentation/changed.ruff check --select F,E9finds nothing new on the changed files: the 7 findings it reports are in the same files on 43e25de. The repo's wider ruff config flags the same typing-style rules (List,Optional) the surrounding code already uses.Checklist
maindocs/VOCABULARY.mdis not in this repo)Notes for reviewers
_remove_default_processor()helper and an INFO log on the bare provider, neither of which remain.### Changedin the core CHANGELOG.python/pyproject.toml,python/CHANGELOG.mdandpython/fi_instrumentation/instrumentation/version.py(0.1.19 to 1.2.0). This PR changes the first two and leavesversion.pyat 0.1.19. That value was already stale at 1.1.0, and nothing infi_instrumentationreads it.version.pyshould read 1.2.1 too. If this PR merges first, feat(python): hide error details with the content hide flags #272's 1.2.0 stands in all three._LAST_RELEASE_WITH_THE_MARK = (1, 1, 0)in the livekit test. That constant changes only if a release with the mark is published after 1.1.0, for example feat(python): hide error details with the content hide flags #272 shipping 1.2.0 before this fix. The livekit lock pins 1.1.0 today.@traceai/fi-core) is written to have the same bug, but a second bug hides it today.register()returns keepsdefaultProcessorAttached = true(typescript/packages/fi-core/src/otel.ts:387), andaddSpanProcessor(otel.ts:403-411) is written to throw the default processor away.@opentelemetry/sdk-trace-base2.x,addSpanProcessordoes nothing: it pushes onto_registeredSpanProcessors, a field 2.x no longer reads. The SDK builds its processor list once, in the constructor (otel.ts:386). So an added processor never receives spans, and the Future AGI exporter is kept.register({ batch: true })(otel.ts:599-601) goes through the same call, so it silently keeps the defaultSimpleSpanProcessor. It also clears_defaultProcessorAttached, a typo fordefaultProcessorAttached.batchsettings, with and without an added processor.addSpanProcessorwork would bring TH-8727 to TypeScript. It must also stopregister()from leaving its exporter marked as a replaceable default (cleardefaultProcessorAttached). That needs its own ticket and fi-core release.java/traceai-java-core/src/main/java/ai/traceai/TraceAI.java:111,117): processors go into upstreamSdkTracerProviderBuilder, which only adds. Not affected.csharp/src/FIInstrumentation/TraceAI.cs:110and theAddOtlpExportercalls after it): uses upstreamTracerProviderBuilder, which only adds. Not affected.main, but open PRs add it.feat/go-sdk, head 529dc6e) adds a Go SDK core ingo/traceaiplus an OpenAI instrumentor.go/anthropic,go/openai,go/a2a,go/genai,go/eino) with examples.sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(...))orWithBatcher(...), and upstream only adds processors. None keeps a replaceable default, so none is affected. Recheck if a Goregister()with its own default processor lands.main042a3fe1) and future-agi/unified-docs (main84895232), these pages describe the old trap as current behaviour. They should say the workaround is only needed on fi-instrumentation-otel 1.1.0 and earlier. The workaround itself does no harm on 1.1.1.public/fi_verify.py:117-129src/pages/docs/cookbook/quickstart/instrument-and-verify.mdx:401-403and:691src/pages/docs/observe/troubleshooting/noisy-or-incomplete-traces.mdx:122adds a processor afterregister(). On 1.1.0 and earlier, that silently drops the Future AGI exporter.enable_*workaround and the mark-dependent test can go.🤖 Generated with Claude Code