Skip to content

Commit 7bbb418

Browse files
committed
fix(metrics): honor send=False, fork-safe locks, flush on module shutdown
Address review of the metrics capture surface: - _do_flush discards the window without transmitting when client.send is False, mirroring event capture (record locally, send nothing). - _reinit_after_fork replaces the metrics locks (_metrics_lock, _lock, _flush_lock) and drops the inherited window/timer in the forked child, so a child can't deadlock on a lock held by a vanished parent thread. - Module-level shutdown() delegates to Client.shutdown() so the final metrics window is flushed for apps using the global SDK lifecycle. Generated-By: PostHog Code Task-Id: 0d65d94c-c18a-4d84-93f6-18eb54876551
1 parent be5e767 commit 7bbb418

5 files changed

Lines changed: 128 additions & 5 deletions

File tree

posthog/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,8 +1132,7 @@ def shutdown() -> None:
11321132
Category:
11331133
Client management
11341134
"""
1135-
_proxy("flush")
1136-
_proxy("join")
1135+
_proxy("shutdown")
11371136

11381137

11391138
def setup() -> Client:

posthog/client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1604,6 +1604,12 @@ def _reinit_after_fork(self):
16041604
self._flag_definition_cache_provider_async_runner = None
16051605
self._flag_definition_cache_provider_async_runner_lock = threading.Lock()
16061606

1607+
# Metrics locks may have been held by a parent thread at fork time; replace
1608+
# them (never acquire them) so the child can't deadlock on a vanished holder.
1609+
self._metrics_lock = threading.Lock()
1610+
if self._metrics is not None:
1611+
self._metrics._reinit_after_fork()
1612+
16071613
# If using Redis cache, we must reinitialize to get a fresh connection (fork-safe).
16081614
# If using Memory cache, we keep it as-is to benefit from the inherited warm cache.
16091615
if isinstance(self.flag_cache, RedisFlagCache):

posthog/metrics_capture.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,14 +376,27 @@ def _capture(
376376
self._fold(state, float(value))
377377
self._arm_flush_timer()
378378

379+
def _reinit_after_fork(self) -> None:
380+
# Runs in a forked child (via the client's os.register_at_fork hook) before
381+
# user code. The inherited locks may be held by parent threads that do not
382+
# exist in the child, so replace them without ever acquiring them.
383+
self._lock = threading.Lock()
384+
self._flush_lock = threading.Lock()
385+
self._pid = os.getpid()
386+
self._drop_inherited_window()
387+
379388
def _reset_after_fork_locked(self) -> None:
380-
# A forked child inherits the parent's window and a timer handle whose thread does
381-
# not exist in the child — without this, the child never flushes (silent total
382-
# loss) and would duplicate the parent's samples if it ever did. Drop both.
389+
# PID-guard fallback for platforms without os.register_at_fork: a forked child
390+
# inherits the parent's window and a timer handle whose thread does not exist
391+
# in the child — without this, the child never flushes (silent total loss) and
392+
# would duplicate the parent's samples if it ever did. Drop both.
383393
pid = os.getpid()
384394
if pid == self._pid:
385395
return
386396
self._pid = pid
397+
self._drop_inherited_window()
398+
399+
def _drop_inherited_window(self) -> None:
387400
self._flush_timer = None
388401
self._series = {}
389402
self._series_cap_warned = False
@@ -447,6 +460,11 @@ def _do_flush(self) -> None:
447460
self._type_by_name = {}
448461
self._type_collision_warned = set()
449462

463+
# send=False mirrors event capture: recording succeeds locally, but
464+
# nothing is transmitted — the flushed window is discarded.
465+
if not getattr(self._client, "send", True):
466+
return
467+
450468
payload = self._build_payload(window)
451469
outcome = self._send(payload)
452470
if outcome == "retry-later":

posthog/test/test_client_fork.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import gc
3+
import signal
34
import unittest
45
import warnings
56
import weakref
@@ -237,6 +238,46 @@ def child_probe():
237238
)
238239
self.assertEqual(result, "ok")
239240

241+
def test_register_at_fork_replaces_metrics_locks_in_child_process(self):
242+
# Locks held at fork time are inherited locked, and their holders don't
243+
# exist in the child — the metrics path must not deadlock on them.
244+
client = Client(FAKE_TEST_API_KEY, send=False)
245+
client.metrics.count("parent.metric")
246+
247+
locks = [
248+
client._metrics_lock,
249+
client.metrics._lock,
250+
client.metrics._flush_lock,
251+
]
252+
253+
def child_probe():
254+
# A deadlocked child gets killed by SIGALRM (a signaled exit fails
255+
# the assertion below) instead of hanging the test forever.
256+
signal.alarm(5)
257+
try:
258+
if not client._metrics_lock.acquire(blocking=False):
259+
return "inherited _metrics_lock still held"
260+
client._metrics_lock.release()
261+
client.metrics.count("child.metric")
262+
client.metrics.flush()
263+
finally:
264+
signal.alarm(0)
265+
return "ok"
266+
267+
for lock in locks:
268+
lock.acquire()
269+
try:
270+
status, result = self._run_fork_probe(child_probe)
271+
finally:
272+
for lock in locks:
273+
lock.release()
274+
client.metrics.reset()
275+
276+
self.assertTrue(
277+
os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0, msg=result
278+
)
279+
self.assertEqual(result, "ok")
280+
240281
def test_register_at_fork_reinitializes_poller_and_sessions_in_child_process(self):
241282
client = Client(
242283
FAKE_TEST_API_KEY,

posthog/test/test_metrics.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import pytest
77

8+
import posthog
89
from posthog.client import Client
910
from posthog.metrics_capture import DEFAULT_HISTOGRAM_BOUNDS
1011
from posthog.version import VERSION
@@ -241,6 +242,27 @@ def test_transient_failure_merges_window_back(self, client):
241242
(dp,) = metric["sum"]["dataPoints"]
242243
assert dp["asDouble"] == 7.0
243244

245+
@pytest.mark.parametrize(
246+
"flush_via",
247+
[
248+
lambda m: m.flush(), # manual flush
249+
lambda m: m._timer_flush(), # what the flush timer invokes
250+
],
251+
ids=["manual", "timer"],
252+
)
253+
def test_send_false_aggregates_but_never_posts(self, flush_via):
254+
# send=False documents "queueing succeeds but events are not sent"; metrics
255+
# must mirror that: fold locally, never hit the transport.
256+
c = Client(FAKE_API_KEY, host="https://us.example.com", send=False, thread=0)
257+
c.metrics.count("jobs.processed")
258+
259+
session = mock_session()
260+
with mock.patch("posthog.metrics_capture._get_session", return_value=session):
261+
flush_via(c.metrics)
262+
c.metrics.reset()
263+
264+
assert not session.post.called
265+
244266
def test_shutdown_flushes_pending_metrics(self):
245267
c = Client(FAKE_API_KEY, host="https://us.example.com", sync_mode=True)
246268
c.metrics.count("m", 1)
@@ -254,6 +276,43 @@ def test_shutdown_flushes_pending_metrics(self):
254276
(metric,) = metrics_from(payload)
255277
assert metric["name"] == "m"
256278

279+
def test_module_shutdown_flushes_default_client_metrics(self):
280+
# Module-level shutdown() must delegate to Client.shutdown(), or the default
281+
# client from posthog.setup() loses its final metrics window.
282+
saved = (
283+
posthog.default_client,
284+
posthog.api_key,
285+
posthog.host,
286+
posthog.sync_mode,
287+
)
288+
posthog.default_client = None
289+
posthog.api_key = FAKE_API_KEY
290+
posthog.host = "https://us.example.com"
291+
posthog.sync_mode = True
292+
try:
293+
posthog.setup().metrics.count("m", 1)
294+
295+
session = mock_session()
296+
with mock.patch(
297+
"posthog.metrics_capture._get_session", return_value=session
298+
):
299+
posthog.shutdown()
300+
301+
assert session.post.called
302+
(metric,) = metrics_from(sent_payload(session))
303+
assert metric["name"] == "m"
304+
305+
# The window was flushed and cleared, not left pending.
306+
payload, _, _ = flush_and_capture(posthog.default_client)
307+
assert payload is None
308+
finally:
309+
(
310+
posthog.default_client,
311+
posthog.api_key,
312+
posthog.host,
313+
posthog.sync_mode,
314+
) = saved
315+
257316

258317
class TestMetricsCrashSafety:
259318
# A telemetry SDK must never raise into the host application — these inputs all

0 commit comments

Comments
 (0)