1414import os
1515import threading
1616from datetime import datetime , timezone
17- from typing import Any , Dict , List , Optional , Set
17+ from typing import Any , Dict , List , Optional
1818
1919from ._capture import capture_event
2020from ._event_types import MCPAnalyticsEventType
2626from .session import resolve_session_id
2727from .session_token import SessionTokenPayload , decode_session_id
2828
29- # Keep strong refs to in-flight capture tasks/futures so they aren't GC'd mid-flight,
30- # and so the asyncio ones can be awaited via drain_pending() before shutdown. Holds
31- # asyncio.Task (running-loop path) or concurrent.futures.Future (sync background-loop path).
32- _BACKGROUND_TASKS : Set [ Any ] = set ()
29+ # Keep strong refs to in-flight capture tasks/futures and their lifecycle owners so
30+ # they aren't GC'd mid-flight and lifecycle drains can select only their own work.
31+ _BACKGROUND_TASKS : Dict [ Any , Any ] = {}
32+ _tasks_lock = threading . Lock ()
3333
3434# A single daemon event loop for hosts with no running loop (sync dispatchers
3535# like PostHogMCP). Created lazily and reused, so we never leak a loop per call.
@@ -44,8 +44,9 @@ def _reinit_background_loop_after_fork() -> None:
4444 been held by a vanished thread. Replace the state without acquiring the old
4545 lock or trying to close the inherited loop, which can no longer be driven.
4646 """
47- global _BACKGROUND_TASKS , _bg_loop , _bg_loop_lock
48- _BACKGROUND_TASKS = set ()
47+ global _BACKGROUND_TASKS , _tasks_lock , _bg_loop , _bg_loop_lock
48+ _BACKGROUND_TASKS = {}
49+ _tasks_lock = threading .Lock ()
4950 _bg_loop = None
5051 _bg_loop_lock = threading .Lock ()
5152
@@ -67,60 +68,74 @@ def _get_background_loop() -> asyncio.AbstractEventLoop:
6768 return _bg_loop
6869
6970
71+ def _track_task (task : Any , owner : Any ) -> None :
72+ with _tasks_lock :
73+ _BACKGROUND_TASKS [task ] = owner
74+ task .add_done_callback (_on_task_done )
75+
76+
7077def _on_task_done (task : Any ) -> None :
71- _BACKGROUND_TASKS .discard (task )
78+ with _tasks_lock :
79+ _BACKGROUND_TASKS .pop (task , None )
7280 try :
7381 if not task .cancelled () and task .exception () is not None :
7482 log (f"background capture task failed: { task .exception ()} " )
7583 except Exception : # noqa: BLE001 - never let bookkeeping raise
7684 pass
7785
7886
79- def fire_and_forget (coro : Optional [Any ]) -> None :
80- """Schedule a capture coroutine without blocking the tool path. No-ops if the
81- coroutine is ``None`` (no sink). Runs on the current loop when there is one,
82- otherwise on a shared daemon loop (sync hosts) — never creates a throwaway loop."""
87+ def fire_and_forget (
88+ coro : Optional [Any ], owner : Any , * , background : bool = False
89+ ) -> None :
90+ """Schedule capture work and associate it with its lifecycle owner.
91+
92+ Async instrumentation uses its current loop. Sync-only owners can request the
93+ shared background loop so their synchronous lifecycle methods can safely drain
94+ captures even when invoked by a host that also has a running event loop.
95+ """
8396 if coro is None :
8497 return
8598 try :
86- asyncio .get_running_loop ()
99+ running_loop = asyncio .get_running_loop ()
87100 except RuntimeError :
88- # No running loop (sync host) — schedule on the shared background loop.
89- future = asyncio .run_coroutine_threadsafe (coro , _get_background_loop ())
90- _BACKGROUND_TASKS .add (future )
91- future .add_done_callback (_on_task_done )
92- return
93- task = asyncio .ensure_future (coro )
94- _BACKGROUND_TASKS .add (task )
95- task .add_done_callback (_on_task_done )
101+ running_loop = None
96102
103+ if background or running_loop is None :
104+ loop = _get_background_loop ()
105+ future = asyncio .run_coroutine_threadsafe (coro , loop )
106+ _track_task (future , owner )
107+ return
97108
98- async def drain_pending () -> None :
99- """Await in-flight capture work before ``posthog.shutdown()`` instead of racing a
100- sleep. Covers both paths: ``asyncio.Task`` (running-loop hosts) and the
101- ``concurrent.futures.Future`` scheduled on the background loop (sync hosts like
102- PostHogMCP) — the latter wrapped so it can be awaited on the current loop."""
103- awaitables : List [Any ] = []
104- for t in list (_BACKGROUND_TASKS ):
105- if isinstance (t , asyncio .Task ):
106- if not t .done ():
107- awaitables .append (t )
108- elif isinstance (t , concurrent .futures .Future ):
109- if not t .done ():
110- awaitables .append (asyncio .wrap_future (t ))
111- if awaitables :
112- await asyncio .gather (* awaitables , return_exceptions = True )
113-
114-
115- def drain_pending_sync (timeout : Optional [float ] = None ) -> None :
116- """Block until background-loop captures finish. For sync hosts (PostHogMCP) that
117- can't await :func:`drain_pending` — call it before ``flush()``/``shutdown()`` so
118- trailing events aren't still in flight when the client tears down."""
119- futures = [
120- t
121- for t in list (_BACKGROUND_TASKS )
122- if isinstance (t , concurrent .futures .Future ) and not t .done ()
123- ]
109+ task = running_loop .create_task (coro )
110+ _track_task (task , owner )
111+
112+
113+ async def drain_pending (owner : Any ) -> None :
114+ """Await this owner's in-flight captures bound to the current event loop."""
115+ loop = asyncio .get_running_loop ()
116+ with _tasks_lock :
117+ tasks = [
118+ task
119+ for task , task_owner in _BACKGROUND_TASKS .items ()
120+ if task_owner is owner
121+ and isinstance (task , asyncio .Task )
122+ and task .get_loop () is loop
123+ and not task .done ()
124+ ]
125+ if tasks :
126+ await asyncio .gather (* tasks , return_exceptions = True )
127+
128+
129+ def drain_pending_sync (owner : Any , timeout : Optional [float ] = None ) -> None :
130+ """Block until this owner's shared-background-loop captures finish."""
131+ with _tasks_lock :
132+ futures = [
133+ task
134+ for task , task_owner in _BACKGROUND_TASKS .items ()
135+ if task_owner is owner
136+ and isinstance (task , concurrent .futures .Future )
137+ and not task .done ()
138+ ]
124139 if futures :
125140 concurrent .futures .wait (futures , timeout = timeout )
126141
@@ -188,7 +203,7 @@ async def _maybe_emit_initialize(
188203 await _apply_event_properties (
189204 data , event , {"method" : "initialize" , "params" : {}}, extra
190205 )
191- fire_and_forget (capture_event (data , event ))
206+ fire_and_forget (capture_event (data , event ), data )
192207
193208
194209async def _apply_event_properties (
@@ -251,7 +266,7 @@ async def prepare_request(
251266 session_id = await resolve_session_id (data , mcp_session_id , token = token )
252267 identify_event = await handle_identify (data , session_id , request , extra )
253268 if identify_event :
254- fire_and_forget (capture_event (data , identify_event ))
269+ fire_and_forget (capture_event (data , identify_event ), data )
255270 await _maybe_emit_initialize (
256271 data , session_id , client_name , client_version , extra , protocol_version
257272 )
@@ -306,7 +321,7 @@ async def record_tool_call(
306321 if props is not None :
307322 event ["properties" ] = props
308323
309- fire_and_forget (capture_event (data , event ))
324+ fire_and_forget (capture_event (data , event ), data )
310325 except Exception as err : # noqa: BLE001 - isolate analytics from the tool path
311326 log (f"record_tool_call failed (event dropped, tool unaffected): { err } " )
312327
@@ -389,7 +404,7 @@ async def record_missing_capability(
389404 event ["user_intent" ] = context .strip ()
390405 event ["user_intent_source" ] = "context_parameter"
391406 await _apply_event_properties (data , event , request , extra )
392- fire_and_forget (capture_event (data , event ))
407+ fire_and_forget (capture_event (data , event ), data )
393408 except Exception as err : # noqa: BLE001 - isolate analytics from the tool path
394409 log (f"record_missing_capability failed (event dropped): { err } " )
395410
@@ -426,6 +441,6 @@ async def record_tools_list(
426441 if error is not None :
427442 event ["error" ] = capture_exception (error )
428443 await _apply_event_properties (data , event , request , extra )
429- fire_and_forget (capture_event (data , event ))
444+ fire_and_forget (capture_event (data , event ), data )
430445 except Exception as err : # noqa: BLE001 - isolate analytics from the tool path
431446 log (f"record_tools_list failed (event dropped): { err } " )
0 commit comments