1616
1717import atexit
1818import os
19+ import sys
1920import threading
21+ import urllib .request
22+ import weakref
2023from queue import Full , Queue
2124from typing import Any , Callable , Dict , List , Optional , cast
2225
@@ -79,6 +82,10 @@ class LangfuseResourceManager:
7982
8083 _instances : Dict [str , "LangfuseResourceManager" ] = {}
8184 _lock = threading .RLock ()
85+ _otel_tracer : Tracer
86+ _media_manager : MediaManager
87+ _media_upload_consumers : List [MediaUploadConsumer ]
88+ _ingestion_consumers : List [ScoreIngestionConsumer ]
8289
8390 @classmethod
8491 def get_singleton_httpx_client (cls ) -> Optional [httpx .Client ]:
@@ -201,6 +208,7 @@ def _initialize_instance(
201208 self .mask = mask
202209 self .mask_otel_spans = mask_otel_spans
203210 self .environment = environment
211+ self ._shutdown = False
204212
205213 # Store additional client settings for get_client() to use
206214 self .timeout = timeout
@@ -216,60 +224,19 @@ def _initialize_instance(
216224 self .span_exporter = span_exporter
217225 self .tracer_provider : Optional [TracerProvider ] = None
218226
219- # API Clients
220-
221- ## API clients must be singletons because the underlying HTTPX clients
222- ## use connection pools with limited capacity. Creating multiple instances
223- ## could exhaust the OS's maximum number of available TCP sockets (file descriptors),
224- ## leading to connection errors.
225- if httpx_client is not None :
226- self .httpx_client = httpx_client
227- else :
228- # Create a new httpx client with additional_headers if provided
229- client_headers = additional_headers if additional_headers else {}
230- self .httpx_client = httpx .Client (timeout = timeout , headers = client_headers )
231-
232- self .api = LangfuseAPI (
233- base_url = base_url ,
234- username = self .public_key ,
235- password = secret_key ,
236- x_langfuse_sdk_name = "python" ,
237- x_langfuse_sdk_version = langfuse_version ,
238- x_langfuse_public_key = self .public_key ,
239- httpx_client = self .httpx_client ,
240- timeout = timeout ,
241- )
242- self .async_api = AsyncLangfuseAPI (
243- base_url = base_url ,
244- username = self .public_key ,
245- password = secret_key ,
246- x_langfuse_sdk_name = "python" ,
247- x_langfuse_sdk_version = langfuse_version ,
248- x_langfuse_public_key = self .public_key ,
249- timeout = timeout ,
250- )
251- score_ingestion_client = LangfuseClient (
252- public_key = self .public_key ,
253- secret_key = secret_key ,
254- base_url = base_url ,
255- version = langfuse_version ,
256- timeout = timeout or 20 ,
257- session = self .httpx_client ,
258- )
227+ self ._custom_httpx_client = httpx_client
228+ self ._init_api_clients ()
259229
260230 # Media
261231 self ._media_upload_enabled = os .environ .get (
262232 LANGFUSE_MEDIA_UPLOAD_ENABLED , "True"
263233 ).lower () not in ("false" , "0" )
264234
265- self ._media_upload_queue : Queue [Any ] = Queue (100_000 )
266- self ._media_manager = MediaManager (
267- api_client = self .api ,
268- httpx_client = self .httpx_client ,
269- media_upload_queue = self ._media_upload_queue ,
270- max_retries = 3 ,
235+ self ._media_upload_thread_count = media_upload_thread_count or max (
236+ int (os .getenv (LANGFUSE_MEDIA_UPLOAD_THREAD_COUNT , 1 )), 1
271237 )
272- self ._media_upload_consumers = []
238+
239+ self ._init_media_manager ()
273240
274241 # OTEL Tracer
275242 if tracing_enabled :
@@ -303,48 +270,207 @@ def _initialize_instance(
303270 attributes = {"public_key" : self .public_key },
304271 )
305272
306- media_upload_thread_count = media_upload_thread_count or max (
307- int (os .getenv (LANGFUSE_MEDIA_UPLOAD_THREAD_COUNT , 1 )), 1
273+ self ._init_consumer_threads ()
274+
275+ # Prompt cache
276+ self .prompt_cache = PromptCache ()
277+
278+ # Register shutdown handler
279+ atexit .register (self .shutdown )
280+
281+ # Register fork handler to reinitialize consumer threads in child process.
282+ # When using Gunicorn with --preload, os.fork() copies memory but not threads
283+ # (POSIX.1: https://pubs.opengroup.org/onlinepubs/9699919799/functions/fork.html).
284+ # Without this, media upload and score ingestion threads are lost after fork,
285+ # causing silent data loss.
286+ #
287+ # Note: LangfuseSpanProcessor (BatchSpanProcessor) already handles fork-safety
288+ # for span export via its own os.register_at_fork. This handler covers the
289+ # remaining background threads managed by LangfuseResourceManager.
290+ #
291+ # weakref.WeakMethod prevents os.register_at_fork from holding a permanent strong
292+ # reference to this instance, which would block garbage collection.
293+ # See: https://github.com/open-telemetry/opentelemetry-python/blob/main/opentelemetry-sdk/src/opentelemetry/sdk/_shared_internal/__init__.py
294+ if hasattr (os , "register_at_fork" ):
295+ weak_reinit = weakref .WeakMethod (self ._at_fork_reinit )
296+ os .register_at_fork (
297+ # Walrus operator resolves the weak reference once and stores it in
298+ # a temporary variable before calling it. This avoids a TOCTOU window
299+ # where GC could collect the referent between checking for None and
300+ # invoking the method.
301+ after_in_child = lambda : (m := weak_reinit ()) and m ()
302+ )
303+
304+ langfuse_logger .info (
305+ f"Startup: Langfuse tracer successfully initialized | "
306+ f"public_key={ self .public_key } | "
307+ f"base_url={ base_url } | "
308+ f"environment={ environment or 'default' } | "
309+ f"sample_rate={ sample_rate if sample_rate is not None else 1.0 } | "
310+ f"media_threads={ self ._media_upload_thread_count } "
311+ )
312+
313+ def _init_media_manager (self ) -> None :
314+ """Initialize or reset media upload state while preserving manager references."""
315+ self ._media_upload_queue : Queue [Any ] = Queue (100_000 )
316+ if hasattr (self , "_media_manager" ):
317+ self ._media_manager .reinitialize (
318+ api_client = self .api ,
319+ httpx_client = self .httpx_client ,
320+ media_upload_queue = self ._media_upload_queue ,
321+ )
322+ else :
323+ self ._media_manager = MediaManager (
324+ api_client = self .api ,
325+ httpx_client = self .httpx_client ,
326+ media_upload_queue = self ._media_upload_queue ,
327+ max_retries = 3 ,
328+ )
329+
330+ self ._media_upload_consumers = []
331+
332+ def _init_api_clients (self ) -> None :
333+ """Initialize HTTP-backed API clients.
334+
335+ Internally-managed httpx clients are recreated when this method is
336+ called after fork. Caller-provided clients are preserved because their
337+ lifecycle belongs to the caller.
338+ """
339+ if self ._custom_httpx_client is not None :
340+ self .httpx_client = self ._custom_httpx_client
341+ else :
342+ client_headers = self .additional_headers if self .additional_headers else {}
343+ self .httpx_client = httpx .Client (
344+ timeout = self .timeout , headers = client_headers
345+ )
346+
347+ self .api = LangfuseAPI (
348+ base_url = self .base_url ,
349+ username = self .public_key ,
350+ password = self .secret_key ,
351+ x_langfuse_sdk_name = "python" ,
352+ x_langfuse_sdk_version = langfuse_version ,
353+ x_langfuse_public_key = self .public_key ,
354+ httpx_client = self .httpx_client ,
355+ timeout = self .timeout ,
356+ )
357+ self .async_api = AsyncLangfuseAPI (
358+ base_url = self .base_url ,
359+ username = self .public_key ,
360+ password = self .secret_key ,
361+ x_langfuse_sdk_name = "python" ,
362+ x_langfuse_sdk_version = langfuse_version ,
363+ x_langfuse_public_key = self .public_key ,
364+ timeout = self .timeout ,
365+ )
366+ self ._score_ingestion_client = LangfuseClient (
367+ public_key = self .public_key ,
368+ secret_key = self .secret_key ,
369+ base_url = self .base_url ,
370+ version = langfuse_version ,
371+ timeout = self .timeout or 20 ,
372+ session = self .httpx_client ,
308373 )
309374
375+ def _init_consumer_threads (self ) -> None :
376+ """Initialize media upload and score ingestion consumer threads."""
310377 if self ._media_upload_enabled :
311- for i in range (media_upload_thread_count ):
378+ for i in range (self . _media_upload_thread_count ):
312379 media_upload_consumer = MediaUploadConsumer (
313380 identifier = i ,
314381 media_manager = self ._media_manager ,
315382 )
316383 media_upload_consumer .start ()
317384 self ._media_upload_consumers .append (media_upload_consumer )
318385
319- # Prompt cache
320- self .prompt_cache = PromptCache ()
321-
322386 # Score ingestion
323387 self ._score_ingestion_queue : Queue [Any ] = Queue (100_000 )
324388 self ._ingestion_consumers = []
325389
326390 ingestion_consumer = ScoreIngestionConsumer (
327391 ingestion_queue = self ._score_ingestion_queue ,
328392 identifier = 0 ,
329- client = score_ingestion_client ,
330- flush_at = flush_at ,
331- flush_interval = flush_interval ,
393+ client = self . _score_ingestion_client ,
394+ flush_at = self . flush_at ,
395+ flush_interval = self . flush_interval ,
332396 max_retries = 3 ,
333397 public_key = self .public_key ,
334398 )
335399 ingestion_consumer .start ()
336400 self ._ingestion_consumers .append (ingestion_consumer )
337401
338- # Register shutdown handler
339- atexit . register ( self . shutdown )
402+ def _at_fork_reinit ( self ) -> None :
403+ """Reinitialize consumer threads after fork in child process.
340404
341- langfuse_logger .info (
342- f"Startup: Langfuse tracer successfully initialized | "
343- f"public_key={ self .public_key } | "
344- f"base_url={ base_url } | "
345- f"environment={ environment or 'default' } | "
346- f"sample_rate={ sample_rate if sample_rate is not None else 1.0 } | "
347- f"media_threads={ media_upload_thread_count or 1 } "
405+ Called automatically via os.register_at_fork() after fork().
406+ Necessary for Gunicorn --preload deployments where os.fork() is used:
407+ threads are not copied to child processes (POSIX standard), so without
408+ reinitialization, the child process has no consumer threads and all
409+ media upload and score ingestion events are silently lost.
410+
411+ Note: LangfuseSpanProcessor (BatchSpanProcessor) handles span export
412+ fork-safety separately via its own os.register_at_fork handler.
413+
414+ Skipped if shutdown() was already called on this instance, to avoid
415+ restarting threads on an intentionally torn-down manager.
416+ """
417+ # The class-level lock may have been held by a thread in the parent at fork time.
418+ # That thread does not exist in the child, so the lock can never be released and
419+ # any attempt to acquire it would deadlock. Replace it before the shutdown check:
420+ # the lock is class-level state needed by the child (e.g. to create a new client)
421+ # even if this particular instance was already shut down.
422+ LangfuseResourceManager ._lock = threading .RLock ()
423+
424+ if self ._shutdown :
425+ return
426+
427+ if sys .platform == "darwin" and not urllib .request .getproxies_environment ():
428+ # urllib proxy discovery falls back to macOS SystemConfiguration APIs that
429+ # are not safe to invoke after fork(). Setting no_proxy="*" makes httpx and
430+ # requests skip that lookup entirely in this child process. Skipped when
431+ # proxies are configured via environment variables: urllib then never touches
432+ # SystemConfiguration (no segfault risk), and overriding no_proxy would
433+ # disable the user's proxy setup process-wide.
434+ os .environ ["no_proxy" ] = "*"
435+ os .environ ["NO_PROXY" ] = "*"
436+
437+ langfuse_logger .debug (
438+ f"[PID { os .getpid ()} ] Fork detected: reinitializing Langfuse consumer threads."
439+ )
440+
441+ # Queues are intentionally recreated after fork. Items enqueued before fork
442+ # belong to the preloaded parent process and must not be processed by every
443+ # worker — otherwise uploads/scores would be duplicated across workers.
444+ #
445+ # Internally-managed httpx clients must also be recreated: fork() duplicates the
446+ # parent's connection pool (TCP socket file descriptors) into the child. Both
447+ # processes then share the same underlying sockets, causing data corruption and
448+ # SSL/TLS state mismatch under concurrent use. Fresh clients start with an empty
449+ # pool owned solely by this child process.
450+ #
451+ # Custom httpx clients provided by the caller are NOT recreated. The fork-inherited
452+ # copy is reused as-is, giving the caller the opportunity to handle process-safety
453+ # themselves (e.g. by registering their own os.register_at_fork handler).
454+ try :
455+ self ._init_api_clients ()
456+ except Exception as e :
457+ langfuse_logger .error (
458+ f"[PID { os .getpid ()} ] Failed to recreate HTTP clients after fork: { e } . "
459+ f"Network requests may fail in this worker."
460+ )
461+
462+ try :
463+ self ._init_media_manager ()
464+ self ._init_consumer_threads ()
465+ self .prompt_cache = PromptCache ()
466+ except Exception as e :
467+ langfuse_logger .error (
468+ f"[PID { os .getpid ()} ] Failed to reinitialize consumer threads after fork: { e } . "
469+ f"Media upload, score ingestion, and prompt cache refresh will be unavailable in this worker."
470+ )
471+
472+ langfuse_logger .debug (
473+ f"[PID { os .getpid ()} ] Langfuse consumer threads and prompt cache reinitialized after fork"
348474 )
349475
350476 @classmethod
@@ -486,6 +612,8 @@ def flush(self) -> None:
486612 langfuse_logger .debug ("Successfully flushed media upload queue" )
487613
488614 def shutdown (self ) -> None :
615+ self ._shutdown = True
616+
489617 # Unregister the atexit handler first
490618 atexit .unregister (self .shutdown )
491619
0 commit comments