Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 49 additions & 17 deletions langfuse/_client/resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,14 @@
id_generator: Optional[IdGenerator] = None,
span_exporter: Optional[SpanExporter] = None,
) -> "LangfuseResourceManager":
if public_key in cls._instances:
return cls._instances[public_key]

with cls._lock:
cached_instance = cls._instances.get(public_key)
if cached_instance is not None and not cached_instance._shutdown:
return cached_instance

if cached_instance is not None:
cls._instances.pop(public_key, None)

if public_key not in cls._instances:
instance = super(LangfuseResourceManager, cls).__new__(cls)

Expand Down Expand Up @@ -226,6 +230,7 @@

self._custom_httpx_client = httpx_client
self._init_api_clients()
self._span_processor: Optional[LangfuseSpanProcessor] = None

# Media
self._media_upload_enabled = os.environ.get(
Expand Down Expand Up @@ -260,12 +265,13 @@
additional_headers=additional_headers,
span_exporter=span_exporter,
media_manager=self._media_manager,
mask_otel_spans=mask_otel_spans,
)
tracer_provider.add_span_processor(langfuse_processor)
self._span_processor = langfuse_processor

self._otel_tracer = tracer_provider.get_tracer(
LANGFUSE_TRACER_NAME,

Check failure on line 274 in langfuse/_client/resource_manager.py

View check run for this annotation

Claude / Claude Code Review

Shut-down span processor stays attached to shared TracerProvider on same-key recreation

This PR's new eviction logic in `__new__` lets a shut-down `LangfuseResourceManager` for a given `public_key` be replaced by a fresh one, but `shutdown()` only stops the old `LangfuseSpanProcessor` — it is never detached from the shared global `TracerProvider` (OTel has no `remove_span_processor` API), so each shutdown+recreate cycle for the same key permanently appends another dead processor to the provider's processor list. This is an unbounded leak in exactly the flow this PR targets (repeate
Comment on lines 268 to 274

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 This PR's new eviction logic in __new__ lets a shut-down LangfuseResourceManager for a given public_key be replaced by a fresh one, but shutdown() only stops the old LangfuseSpanProcessor — it is never detached from the shared global TracerProvider (OTel has no remove_span_processor API), so each shutdown+recreate cycle for the same key permanently appends another dead processor to the provider's processor list. This is an unbounded leak in exactly the flow this PR targets (repeated same-key client recreation in tests/app restarts), and it also means every subsequent span export/force_flush call additionally iterates over the accumulated dead processors.

Extended reasoning...

The bug: _init_tracer_provider() (bottom of resource_manager.py) only constructs a new TracerProvider when the OTel global default is still a ProxyTracerProvider. The very first Langfuse client created in a process calls otel_trace_api.set_tracer_provider(provider), which is a one-time, non-overridable action in the OTel SDK. Every subsequent call to _init_tracer_provider() — for the same public_key or a different one — hits the else branch and simply returns that one shared global provider.

In _initialize_instance() (lines 262-274), each time a LangfuseResourceManager is constructed for a key with tracing_enabled=True, a brand-new LangfuseSpanProcessor is created and appended to that shared provider via tracer_provider.add_span_processor(langfuse_processor). add_span_processor on OTel's SDK TracerProvider only ever appends to an internal list; there is no public API to remove an entry once added.

shutdown() (lines ~648-660) sets self._shutdown = True, evicts the instance from _instances, flushes, joins the consumer threads, and finally calls self._span_processor.shutdown(). That stops the BatchSpanProcessor background thread and shuts down its exporter, but it does not call anything on self.tracer_provider to detach/remove the processor — and there is no such API to call. The dead processor object stays registered on the shared provider forever.

Why this PR changes the picture: Before this PR, __new__ returned the cached instance for a public_key unconditionally (if public_key in cls._instances: return cls._instances[public_key]), even if it had already been shut down. So calling Langfuse(public_key=pk) again after shutdown() never constructed a new manager and never added a second processor — the leak path was unreachable for the same key. This PR's whole point is to change that: __new__ now pops a shut-down instance out of _instances and builds a genuinely fresh LangfuseResourceManager, which runs _initialize_instance() again and appends a brand-new LangfuseSpanProcessor to the shared provider. This is precisely the scenario exercised by the PR's own new test, test_shutdown_evicts_manager_and_rejects_stale_client_tasks (shutdown → construct a fresh client for the same key), and it's the pytest/app-restart use case the PR description says it is fixing.

Step-by-step proof:

  1. Process starts, first Langfuse(public_key="pk") is created anywhere. _init_tracer_provider() sees a ProxyTracerProvider default, so it creates provider_A and calls set_tracer_provider(provider_A). _initialize_instance() builds processor_1 and calls provider_A.add_span_processor(processor_1).
  2. client.shutdown() is called. _span_processor.shutdown() stops processor_1's thread/exporter, but provider_A._active_span_processor (its internal composite) still holds processor_1.
  3. Langfuse(public_key="pk") is constructed again (e.g. a new pytest test, or an app restarting the client). __new__ sees the cached instance's _shutdown == True, pops it from _instances, and builds a fresh manager. _init_tracer_provider() now sees the global default is provider_A (not a ProxyTracerProvider anymore), so it returns provider_A unchanged. _initialize_instance() builds processor_2 and calls provider_A.add_span_processor(processor_2).
  4. provider_A now holds both the dead processor_1 and the live processor_2. Repeat steps 2-3 N times (e.g. N tests in a suite that each create/shutdown a same-key client) and provider_A accumulates N dead processors that are never freed — each one still holding its exporter, HTTP client references, and internal buffers.
  5. Every span emitted afterward, and every force_flush()/shutdown of provider_A, now iterates over all N+1 processors, so the overhead (not just memory) grows with the number of create/shutdown cycles.

Impact: In a long-running process (e.g. a web app that reconstructs its Langfuse client on config reload) or in a test suite that repeatedly builds/tears-down same-key clients — exactly the pattern this PR's new test and its stated goal cover — this leaks a LangfuseSpanProcessor (plus its exporter and any queued references) per cycle, unboundedly. It's not a crash or incorrect trace data (the dead processors are harmless no-ops after shutdown()), but it is a genuine, PR-introduced resource leak in the exact code path this PR adds.

Suggested fix: Track the processor per-manager and either (a) reuse/replace it in place via a wrapper that supports swapping its inner processor without needing a new add_span_processor call, or (b) give each LangfuseResourceManager its own isolated TracerProvider instead of relying on the ambient global one when one isn't explicitly passed in, so a shut-down manager's provider (and all its processors) can simply be dropped and garbage collected.

langfuse_version,
attributes={"public_key": self.public_key},
)
Expand Down Expand Up @@ -476,11 +482,12 @@
@classmethod
def reset(cls) -> None:
with cls._lock:
for key in cls._instances:
cls._instances[key].shutdown()

instances = list(cls._instances.values())
cls._instances.clear()

for instance in instances:
instance.shutdown()

def add_score_task(self, event: dict, *, force_sample: bool = False) -> None:
try:
# Sample scores with the same sampler that is used for tracing
Expand All @@ -505,16 +512,23 @@
is not None # do not sample out session / dataset run scores
else True
)
)

if should_sample:
langfuse_logger.debug(
f"Score: Enqueuing event type={event['type']} for trace_id={event['body'].trace_id} name={event['body'].name} value={event['body'].value}"
)
self._score_ingestion_queue.put(event, block=False)
with self._lock:
if self._shutdown:
langfuse_logger.warning(
"Score: Dropping event because the Langfuse client has already been shut down."
)
return

langfuse_logger.debug(
f"Score: Enqueuing event type={event['type']} for trace_id={event['body'].trace_id} name={event['body'].name} value={event['body'].value}"
)
self._score_ingestion_queue.put(event, block=False)

except Full:
langfuse_logger.warning(

Check warning on line 531 in langfuse/_client/resource_manager.py

View check run for this annotation

Claude / Claude Code Review

add_score_task/add_trace_task lock on the class-level singleton lock, serializing enqueue across all clients

add_score_task/add_trace_task (lines 518, 548) now do `with self._lock:`, but LangfuseResourceManager never defines an instance-level `self._lock` — only the class attribute `_lock = threading.RLock()` exists (shared across all clients). This makes every score/trace enqueue in the process contend on the same lock that `__new__` holds for the full duration of constructing a brand-new manager for any public_key, and that `shutdown()`/`reset()` hold while evicting instances — a cross-client content
Comment on lines 515 to 531

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 add_score_task/add_trace_task (lines 518, 548) now do with self._lock:, but LangfuseResourceManager never defines an instance-level self._lock — only the class attribute _lock = threading.RLock() exists (shared across all clients). This makes every score/trace enqueue in the process contend on the same lock that __new__ holds for the full duration of constructing a brand-new manager for any public_key, and that shutdown()/reset() hold while evicting instances — a cross-client contention point that didn't exist before this PR. Recommend using a dedicated per-instance lock (e.g. self._state_lock) instead.

Extended reasoning...

The bug: LangfuseResourceManager only ever defines _lock as a class attribute — _lock = threading.RLock() at the class body level, later reassigned at the class level again in _at_fork_reinit (LangfuseResourceManager._lock = threading.RLock()). No _initialize_instance (or anywhere else) ever assigns an instance-level self._lock. That means when the new code in add_score_task (line 518) and add_trace_task (line 548) does with self._lock: to guard the shutdown check + queue.put, Python attribute lookup falls through to the class attribute — so self._lock resolves to the exact same RLock object used by __new__ (line ~137) to guard the entire _instances singleton registry, and used by reset() (line ~484) and shutdown() (line ~636).\n\nWhy this matters: __new__ acquires cls._lock and holds it for the entire duration of constructing a brand-new resource manager when a client for a new (or evicted) public_key is created — this includes _initialize_instance, which builds httpx/OTEL clients, sets up the span processor, and starts consumer threads (thread.start()). Before this PR, add_score_task/add_trace_task took no lock at all; each client's own Queue already provides thread-safe enqueue, so different clients' enqueues were fully independent. After this PR, every score/trace enqueue for every client in the process now contends on this single process-wide lock.\n\nConcrete walkthrough: Suppose a process has two Langfuse clients, A (public_key="a") and B (public_key="b"), both already constructed and issuing scores continuously. Now a third client C is being constructed for public_key="c" (e.g., a multi-tenant server discovering a new tenant, or a pytest suite that creates+shuts down clients between tests). Thread 1 calls LangfuseResourceManager(public_key="c", ...), enters __new__, acquires cls._lock, and begins _initialize_instance — creating httpx clients, setting up the OTEL tracer/span processor, and starting the consumer thread. This can take non-trivial wall-clock time (network-adjacent setup, thread spawn). Meanwhile, thread 2 calls client_a.create_score(...), which eventually reaches add_score_task, which does with self._lock: — but self._lock for client A is the same object as cls._lock currently held by thread 1's construction of client C. Thread 2 blocks until client C's construction finishes, even though client A's queue and consumer are completely unrelated and idle. The same happens for client B's enqueues, and for any enqueue happening while shutdown() or reset() is running for an unrelated key.\n\nWhy nothing currently prevents this: the per-instance Queue objects (_score_ingestion_queue) are already internally synchronized and were sufficient for safe concurrent enqueue before this PR; the new locking was added only to make the shutdown-check-then-enqueue sequence atomic per-instance (to fix the real bug this PR targets — enqueuing into a queue whose consumer has already stopped). That's a legitimate goal, but it should use a lock scoped to the instance, not the class-level singleton-registry lock. Since self._lock was never assigned per-instance, the code accidentally reuses the class lock.\n\nImpact: this is a contention/latency regression, not a correctness break — queue.put(..., block=False) cannot deadlock, and the critical sections in __new__/shutdown/reset are typically short-lived and infrequent relative to the hot-path enqueue calls. In single-client deployments the effect is negligible since construction precedes any enqueues. But in multi-tenant setups or test suites that repeatedly construct/shut down clients concurrently with active traffic on other keys, this reintroduces a global choke point on the ingestion hot path that the pre-PR code never had.\n\nFix: add a dedicated per-instance lock in _initialize_instance (e.g. self._state_lock = threading.Lock()) and use that in add_score_task/add_trace_task/shutdown instead of self._lock, decoupling the per-instance shutdown-guard from the class-level singleton-registry lock.

"System overload: Score ingestion queue has reached capacity (100,000 items). Score will be dropped. Consider increasing flush frequency or decreasing event volume."
)

Expand All @@ -531,10 +545,17 @@
event: dict,
) -> None:
try:
langfuse_logger.debug(
f"Trace: Enqueuing event type={event['type']} for trace_id={event['body'].id}"
)
self._score_ingestion_queue.put(event, block=False)
with self._lock:
if self._shutdown:
langfuse_logger.warning(
"Trace: Dropping event because the Langfuse client has already been shut down."
)
return

langfuse_logger.debug(
f"Trace: Enqueuing event type={event['type']} for trace_id={event['body'].id}"
)
self._score_ingestion_queue.put(event, block=False)

except Full:
langfuse_logger.warning(
Expand Down Expand Up @@ -612,13 +633,24 @@
langfuse_logger.debug("Successfully flushed media upload queue")

def shutdown(self) -> None:
self._shutdown = True
with self._lock:
if self._shutdown:
return
Comment on lines +637 to +638

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for an in-progress shutdown to finish

When two wrappers sharing this manager call shutdown() concurrently, the second caller observes _shutdown immediately after the first sets it and returns even though the first may still be flushing network batches or joining workers. Code relying on shutdown() having completed can then tear down dependencies or exit while export is still underway; idempotence should make later callers wait for completion rather than return on the start-state flag.

AGENTS.md reference: AGENTS.md:L134-L134

Useful? React with 👍 / 👎.


self._shutdown = True
if self._instances.get(self.public_key) is self:
self._instances.pop(self.public_key)
self._media_manager.begin_shutdown()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep media active while flushing pending spans

When shutdown begins with pending OpenTelemetry spans containing base64 media attributes, begin_shutdown() runs before flush(). The transforming span exporter therefore sees the media manager as shut down, returns the original data URI, and exports the span without queuing its media upload; large payloads may also cause the span export to be rejected. Media admission should remain enabled through the tracer-provider flush, then be closed before joining the media queue.

AGENTS.md reference: AGENTS.md:L134-L134

Useful? React with 👍 / 👎.


# Unregister the atexit handler first
atexit.unregister(self.shutdown)

self.flush()
self._stop_and_join_consumer_threads()
try:
self.flush()
finally:
self._stop_and_join_consumer_threads()

Check failure on line 651 in langfuse/_client/resource_manager.py

View check run for this annotation

Claude / Claude Code Review

shutdown() marks media as shut down before flush(), dropping media from unexported spans

shutdown() now calls self._media_manager.begin_shutdown() before self.flush(), so any span still buffered in the BatchSpanProcessor at shutdown time has its media silently dropped instead of uploaded during the force-flush that is supposed to export it. This mainly affects media embedded via third-party OTEL instrumentation (raw base64 attributes only processed at export time), which is exactly the case flush()/shutdown() exists to protect. Move begin_shutdown() to run after flush() completes.
Comment on lines +636 to +651

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 shutdown() now calls self._media_manager.begin_shutdown() before self.flush(), so any span still buffered in the BatchSpanProcessor at shutdown time has its media silently dropped instead of uploaded during the force-flush that is supposed to export it. This mainly affects media embedded via third-party OTEL instrumentation (raw base64 attributes only processed at export time), which is exactly the case flush()/shutdown() exists to protect. Move begin_shutdown() to run after flush() completes.

Extended reasoning...

The bug: In LangfuseResourceManager.shutdown() (resource_manager.py:636-651), self._media_manager.begin_shutdown() is invoked inside the very first locked block, immediately setting MediaManager._shutdown = True. Only afterward does self.flush() run, which calls self.tracer_provider.force_flush() to force the BatchSpanProcessor to export any spans that are still sitting in its internal buffer (i.e. spans that have not yet hit the normal batch-size/interval trigger).

The code path that triggers it: Export goes through LangfuseTransformingSpanExporter.export() -> _process_media_attributes() -> _process_media_attribute_value() -> MediaManager._find_and_process_media() (span_exporter.py:108-249). This PR added a guard at the top of _find_and_process_media (media_manager.py:111-116) that checks self._shutdown and, if true, returns the data completely unprocessed with only a warning log — no extraction of the base64 payload, no LangfuseMedia object created, no enqueue onto the media upload queue. Because begin_shutdown() already ran before flush() triggers this export, every span force-flushed during shutdown hits this early return.

Why nothing else prevents it: The media upload consumer threads are still alive at this point — they are only paused/joined later, in _stop_and_join_consumer_threads(), which shutdown() calls in the finally block after flush(). So structurally there is no reason the media couldn't be uploaded; the only thing blocking it is that _find_and_process_media now refuses to even enqueue the job because _shutdown was flipped too early. Before this PR, MediaManager had no _shutdown flag at all, so this force_flush-triggered processing worked correctly and the subsequent self._media_upload_queue.join() (still inside flush()) would wait for the real upload to complete.

Scope: This does not affect the common case of media created via the Langfuse SDK's own span/generation API, since _process_media_and_apply_mask already converts base64 into a @@@langfuseMedia:...@@@ reference and enqueues the upload synchronously at span-creation time (span.py:603) — by the time export runs, _find_and_process_media sees the reference token and no-ops regardless of _shutdown. The affected path is media that only becomes visible at export time: raw base64 attributes set by third-party OTEL instrumentation (e.g. OpenInference/OpenLLMetry vision instrumentation) that bypass the Langfuse SDK's span-creation hooks entirely. For those spans, export is the only place media is ever extracted, and that is precisely the moment this PR now blocks.

Step-by-step proof:

  1. A third-party OTEL instrumentation library creates an OTEL span with an attribute containing a raw base64 image (a data:image/png;base64,... string), without going through Langfuse's SDK-level span API.
  2. This span has not yet been auto-flushed by the BatchSpanProcessor (still within its batch buffer) when the application calls client.shutdown().
  3. shutdown() acquires self._lock, sets self._shutdown = True, and calls self._media_manager.begin_shutdown(), which sets MediaManager._shutdown = True — all before any flush has occurred.
  4. shutdown() calls self.flush(), which calls self.tracer_provider.force_flush(), forcing the BatchSpanProcessor to export the buffered span immediately.
  5. Export reaches LangfuseTransformingSpanExporter.export() -> _process_media_attributes -> MediaManager._find_and_process_media() for the base64 attribute.
  6. The new guard at the top of _find_and_process_media sees self._shutdown is True and returns the raw data unchanged, logging only a warning — no UploadMediaJob is ever created or enqueued.
  7. flush() then calls self._media_upload_queue.join(), which returns immediately since nothing was ever enqueued for this media.
  8. The span is exported to Langfuse with the raw, un-uploaded base64 instead of a Langfuse media reference — the image is never uploaded to media storage, a silent data loss that the flush/shutdown mechanism exists specifically to prevent.

The fix: Move the self._media_manager.begin_shutdown() call to run after self.flush() completes (e.g. into the finally block alongside _stop_and_join_consumer_threads()), so that any force-flushed spans are still processed for media before the manager is marked as shut down.

if self._span_processor is not None:
self._span_processor.shutdown()


def _init_tracer_provider(
Expand Down
37 changes: 30 additions & 7 deletions langfuse/_task_manager/media_manager.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import threading
import time
from queue import Empty, Full, Queue
from typing import Any, Callable, Optional, TypeVar, cast
Expand Down Expand Up @@ -42,6 +43,8 @@ def __init__(
self._httpx_client = httpx_client
self._queue = media_upload_queue
self._max_retries = max_retries
self._state_lock = threading.Lock()
self._shutdown = False
self._enabled = os.environ.get(
LANGFUSE_MEDIA_UPLOAD_ENABLED, "True"
).lower() not in ("false", "0")
Expand All @@ -53,9 +56,15 @@ def reinitialize(
httpx_client: httpx.Client,
media_upload_queue: Queue,
) -> None:
self._api_client = api_client
self._httpx_client = httpx_client
self._queue = media_upload_queue
with self._state_lock:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reinitialize the media state lock after fork

If another thread holds _state_lock when a preloaded process forks, the child inherits the locked state without the owning thread, and its registered _at_fork_reinit() handler blocks forever here while calling reinitialize(). The resource manager already replaces its class lock for this reason; the media manager's newly introduced lock also needs to be replaced in the child before it is acquired.

AGENTS.md reference: AGENTS.md:L134-L134

Useful? React with 👍 / 👎.

self._api_client = api_client
self._httpx_client = httpx_client
self._queue = media_upload_queue
self._shutdown = False

def begin_shutdown(self) -> None:
with self._state_lock:
self._shutdown = True

def process_next_media_upload(self) -> None:
try:
Expand Down Expand Up @@ -99,6 +108,13 @@ def _find_and_process_media(
if not self._enabled:
return data

with self._state_lock:
if self._shutdown:
logger.warning(
"Media: Skipping upload because the Langfuse client has already been shut down."
)
return data

seen = set()
max_levels = 10

Expand Down Expand Up @@ -279,10 +295,17 @@ def _process_media(
field=field,
)

self._queue.put(
item=upload_media_job,
block=False,
)
with self._state_lock:
if self._shutdown:
logger.warning(
f"Media: Skipping upload for media_id={media._media_id} because the Langfuse client has already been shut down."
)
return

self._queue.put(
item=upload_media_job,
block=False,
)
logger.debug(
f"Queue: Enqueued media ID {media._media_id} for upload processing | trace_id={trace_id} | field={field}"
)
Expand Down
108 changes: 107 additions & 1 deletion tests/unit/test_resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@
class NoOpSpanExporter(SpanExporter):
"""Minimal exporter used to verify configuration propagation."""

def __init__(self) -> None:
self.shutdown_count = 0

def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
return SpanExportResult.SUCCESS

def shutdown(self) -> None:
pass
self.shutdown_count += 1


def test_get_client_preserves_all_settings(monkeypatch):
Expand Down Expand Up @@ -172,6 +175,109 @@ def test_media_upload_consumer_signal_shutdown_wakes_blocked_thread():
assert not consumer.is_alive()


def test_shutdown_evicts_manager_and_rejects_stale_client_tasks(monkeypatch):
monkeypatch.setenv("LANGFUSE_MEDIA_UPLOAD_ENABLED", "false")

with LangfuseResourceManager._lock:
LangfuseResourceManager._instances.clear()

old_exporter = NoOpSpanExporter()
settings = {
"public_key": "pk-shutdown-reinit",
"secret_key": "sk-shutdown-reinit",
"span_exporter": old_exporter,
}
first_client = Langfuse(**settings)
stale_client = Langfuse(**settings)
old_manager = first_client._resources

assert old_manager is not None
assert stale_client._resources is old_manager

first_client.shutdown()

assert old_manager._shutdown
assert settings["public_key"] not in LangfuseResourceManager._instances
assert not old_manager._ingestion_consumers[0].is_alive()
assert old_exporter.shutdown_count == 1

stale_client.create_score(name="quality", value=1.0)
stale_client._create_trace_tags_via_ingestion(
trace_id="0" * 32,
tags=["after-shutdown"],
)

assert old_manager._score_ingestion_queue.unfinished_tasks == 0
stale_client.shutdown()

fresh_client = Langfuse(
public_key=settings["public_key"],
secret_key=settings["secret_key"],
span_exporter=NoOpSpanExporter(),
)
fresh_manager = fresh_client._resources

assert fresh_manager is not None
assert fresh_manager is not old_manager
assert fresh_manager._ingestion_consumers[0].is_alive()

fresh_client.shutdown()


def test_shutdown_rejects_stale_media_tasks(monkeypatch):
monkeypatch.setenv("LANGFUSE_MEDIA_UPLOAD_ENABLED", "true")

with LangfuseResourceManager._lock:
LangfuseResourceManager._instances.clear()

client = Langfuse(
public_key="pk-media-shutdown",
secret_key="sk-media-shutdown",
span_exporter=NoOpSpanExporter(),
)
manager = client._resources
assert manager is not None

client.shutdown()

data_uri = "data:text/plain;base64,SGVsbG8="
processed = manager._media_manager._find_and_process_media(
data=data_uri,
trace_id="0" * 32,
observation_id="0" * 16,
field="input",
)

assert processed == data_uri
assert manager._media_upload_queue.unfinished_tasks == 0


def test_reset_handles_shutdown_eviction(monkeypatch):
monkeypatch.setenv("LANGFUSE_MEDIA_UPLOAD_ENABLED", "false")

with LangfuseResourceManager._lock:
LangfuseResourceManager._instances.clear()

first_client = Langfuse(
public_key="pk-reset-first",
secret_key="sk-reset-first",
span_exporter=NoOpSpanExporter(),
)
second_client = Langfuse(
public_key="pk-reset-second",
secret_key="sk-reset-second",
span_exporter=NoOpSpanExporter(),
)

LangfuseResourceManager.reset()

assert LangfuseResourceManager._instances == {}
assert first_client._resources is not None
assert first_client._resources._shutdown
assert second_client._resources is not None
assert second_client._resources._shutdown


def test_at_fork_reinit_creates_new_queues_and_consumers(monkeypatch):
"""_at_fork_reinit() must replace queues and start fresh consumer threads."""
monkeypatch.setenv("LANGFUSE_MEDIA_UPLOAD_ENABLED", "false")
Expand Down