Skip to content

tracing: setup_tracing starts an orphaned BatchSpanProcessor thread when a provider is already registered #292

Open

Description

@pontino

Problem

setup_tracing() in sdk/eggai/tracing.py builds a TracerProvider, a span exporter and a BatchSpanProcessor before it checks whether a global provider is already registered. Only the last step is conditional:

provider = TracerProvider(resource=resource)
...
provider.add_span_processor(BatchSpanProcessor(span_exporter))

if isinstance(trace.get_tracer_provider(), ProxyTracerProvider):
    trace.set_tracer_provider(provider)

_backend = _OtelBackend(trace.get_tracer("eggai", version("eggai")))

BatchSpanProcessor.__init__ starts a daemon thread (OtelBatchSpanRecordProcessor) right away. Verified on opentelemetry-sdk 1.44.0. When a provider is already registered, the new provider is dropped but the thread keeps running for the life of the process, and the OTLP exporter behind it stays allocated. No span ever reaches it. The eggai backend is built from the global tracer, so tracing itself keeps working; the waste is silent.

This is easy to hit with the documented usage. The README says tracing auto-activates at import when OTEL_EXPORTER_OTLP_ENDPOINT is set, and also says to call setup_tracing(service_name="my-agent") once at startup. Doing both, as documented, runs the orphan path on every start. Any application that configures OpenTelemetry itself before importing eggai hits it too.

A second effect of the same code: when a provider is already registered, the caller's exporter, endpoint and service_name arguments are ignored without any signal.

setup_tracing has no test coverage today. The tests in tests/test_tracing.py install a shared provider at module import and drive _OtelBackend directly.

Options

Option A: build the provider only when it will be installed

Move the proxy check to the top of setup_tracing. If a real provider is already registered, only build the eggai backend from the global tracer. Otherwise build the resource, exporter, processor and provider, and install it.

Observable behavior stays the same for every caller. The orphaned thread and exporter go away. One file, small diff.

Tests:

  • In-process test for the already-registered path. tests/test_tracing.py already owns the global provider, so calling setup_tracing(exporter=InMemorySpanExporter()) there exercises exactly this branch. Assert the set of live thread names is unchanged, the backend is an _OtelBackend, and a publish lands in the module's shared exporter. This test fails on current main with the extra OtelBatchSpanRecordProcessor thread.
  • Subprocess test for the proxy path, since the global provider cannot be reset in-process. The child calls setup_tracing(exporter="console"), publishes one message over InMemoryTransport and exits. Assert stdout contains an eggai.publish span. This also proves the batch processor flushes at exit.

Option B: attach the requested exporter to the existing provider

When a provider is already registered and is an SDK TracerProvider, call add_span_processor on it with the requested exporter, so the exporter and endpoint arguments are honored instead of ignored.

This is a behavior change. With the current auto-activation, an application that sets the env var and also calls setup_tracing() would export every span twice. It also cannot honor service_name, since the resource belongs to the existing provider. The Langfuse Python SDK takes this route and adds its own span processor to an existing provider.

Option C: drop import-time auto-activation

Remove the module-level block that calls setup_tracing() when OTEL_EXPORTER_OTLP_ENDPOINT is set. Applications activate tracing with one explicit call, or with the standard opentelemetry-instrument launcher.

This removes the root cause of the double setup in the README, and removes an import side effect that spawns threads and opens a gRPC channel. It is a breaking change for anyone relying on env-var-only activation, and needs a README and changelog update.

Comparison with common practice

The OpenTelemetry specification separates the API from the SDK on purpose. The guidance for libraries is that they call get_tracer(name, version) and never configure a provider or exporter. The application, or the opentelemetry-instrument launcher, owns setup. The official opentelemetry-instrumentation-* packages follow this strictly. Under that model setup_tracing would not exist in eggai at all.

Libraries that ship a convenience helper anyway, such as Traceloop's OpenLLMetry, Langfuse and Sentry's OpenTelemetry integration, almost all check for ProxyTracerProvider before installing their own provider, because the global provider can only be set once and clobbering the application's is hostile. eggai already does this. Option A keeps eggai in line with that group and fixes the waste. Option B has precedent in Langfuse. Option C moves eggai toward the specification position.

Import-time activation from env vars has no precedent in that group. OpenTelemetry reads the same env vars, but through an explicit call or the launcher, never as an import side effect.

Recommendation

Do Option A now as a small, behavior-preserving fix with the two tests above. Treat Option C as a separate decision, since it changes documented behavior.

Verification for Option A

ruff check, ruff format --check, mypy and the full pytest run with all extras installed, plus a run of the new thread test against unpatched tracing.py to show it fails.

Related: #290 fixed the type errors in tracing.py and noted this as a follow-up.

Activity

  1. added
    bugSomething isn't working
    sdkSDK package changes
    on Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsdkSDK package changes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions