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
11 changes: 8 additions & 3 deletions posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,9 +337,12 @@ def get_tags() -> Dict[str, Any]:
Set to 0 to disable retries.
super_properties: Properties merged into every captured event.
metrics: Config dict for the ``client.metrics`` API (``service_name``,
``service_version``, ``environment``, ``flush_interval``, ...). Applied
when ``setup()`` builds the global client, or on a later ``setup()``
call if the metrics API hasn't been used yet.
``service_version``, ``environment``, ``flush_interval``,
``autocapture_interval``, ...). Applied when ``setup()`` builds the
global client, or on a later ``setup()`` call if the metrics API hasn't
been used yet.
metrics_autocapture: When True, periodically sample process/host runtime
metrics and report them through the ``metrics`` client. Off by default.
enable_exception_autocapture: Automatically capture uncaught exceptions.
log_captured_exceptions: Also log exceptions captured by error tracking.
project_root: Root path used to determine in-app exception stack frames.
Expand Down Expand Up @@ -396,6 +399,7 @@ def get_tags() -> Dict[str, Any]:
feature_flags_request_max_retries = 1 # type: int
super_properties = None # type: Optional[Dict]
metrics = None # type: Optional[Dict]
metrics_autocapture = False # type: bool
enable_exception_autocapture = False # type: bool
log_captured_exceptions = False # type: bool
# Used to determine in app paths for exception autocapture. Defaults to the current working directory
Expand Down Expand Up @@ -1259,6 +1263,7 @@ def setup() -> Client:
feature_flags_request_max_retries=feature_flags_request_max_retries,
super_properties=super_properties,
metrics=metrics,
metrics_autocapture=metrics_autocapture,
# TODO: Currently this monitoring begins only when the Client is initialised (which happens when you do something with the SDK)
# This kind of initialisation is very annoying for exception capture. We need to figure out a way around this,
# or deprecate this proxy option fully (it's already in the process of deprecation, no new clients should be using this method since like 5-6 months)
Expand Down
56 changes: 55 additions & 1 deletion posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
from posthog._disabled_lane_queue import _DisabledLaneQueue
from posthog.args import ID_TYPES, ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
from posthog.metrics_capture import PostHogMetrics
from posthog.runtime_metrics import (
MetricsAutocapture,
_DEFAULT_SAMPLE_INTERVAL_SECONDS,
)
from posthog.capture_compression import (
CaptureCompression,
_resolve_capture_compression,
Expand Down Expand Up @@ -708,6 +712,9 @@ def __init__(
capture_trace_context=False,
_use_ai_lane=False,
_enable_multimodal_capture=False,
# Keyword-only and last so it never shifts an existing positional slot.
*,
metrics_autocapture: bool = False,
):
"""
Initialize a new PostHog client instance.
Expand Down Expand Up @@ -773,6 +780,15 @@ def __init__(
skips string truncation and passes media (base64/data URIs)
through unredacted. ``privacy_mode`` always wins. Defaults to
False.
metrics_autocapture: When True, periodically sample a small,
low-cardinality set of process/host runtime metrics (CPU time,
memory, threads, GC, load average; richer values when ``psutil``
is installed) and report them through the ``metrics`` client to
PostHog's Metrics product. Off by default and no required
dependencies. Configure the cadence with
``metrics={"autocapture_interval": 30}`` (seconds, default 60)
and identity via the ``metrics`` resource attributes (a per-process
``service.instance.id`` of ``hostname-pid`` is set when absent).
before_send: Optional callback that can modify or drop events before
upload. Return ``None`` to drop an event.
flag_fallback_cache_url: Optional feature flag fallback cache URL,
Expand Down Expand Up @@ -901,6 +917,8 @@ def __init__(
self._metrics_config = metrics
self._metrics: Optional[PostHogMetrics] = None
self._metrics_lock = threading.Lock()
self._metrics_autocapture_enabled = metrics_autocapture
self._metrics_autocapture: Optional[MetricsAutocapture] = None
# `_use_ai_lane` / `_enable_multimodal_capture` are deprecated aliases.
self.enable_full_ai_capture = (
enable_full_ai_capture is True
Expand Down Expand Up @@ -1012,6 +1030,9 @@ def __init__(
refill_interval_seconds=self.exception_autocapture_refill_interval_seconds,
)

if self._metrics_autocapture_enabled and not self.disabled:
self._start_metrics_autocapture()

if not sync_mode and send:
# On program exit, allow the consumer threads to exit cleanly.
# This prevents exceptions and a messy shutdown when the
Expand Down Expand Up @@ -2217,6 +2238,10 @@ def _reinit_after_fork(self):
self._metrics_lock = threading.Lock()
if self._metrics is not None:
self._metrics._reinit_after_fork()
# The sampler thread does not survive fork(); restart it (with a fresh
# per-process instance id) so each preforked worker reports on itself.
if self._metrics_autocapture is not None:
self._metrics_autocapture._reinit_after_fork()

# If using Redis cache, we must reinitialize to get a fresh connection (fork-safe).
# If using Memory cache, we keep it as-is to benefit from the inherited warm cache.
Expand Down Expand Up @@ -2439,6 +2464,24 @@ def metrics(self) -> PostHogMetrics:
self._metrics = PostHogMetrics(self, None)
return self._metrics

def _start_metrics_autocapture(self) -> None:
# Enabling autocapture never raises into the host application: a bad
# config or a platform quirk degrades to no sampling (raise only in debug).
try:
config = (
self._metrics_config if isinstance(self._metrics_config, dict) else {}
)
interval = config.get(
"autocapture_interval", _DEFAULT_SAMPLE_INTERVAL_SECONDS
)
autocapture = MetricsAutocapture(self.metrics, interval=interval)
autocapture.start()
self._metrics_autocapture = autocapture
except Exception as e:
if self.debug:
raise e
self.log.exception(f"Error starting metrics autocapture: {e}")

def flush(self, timeout_seconds: Optional[float] = 10) -> None:
"""
Force a flush from the internal queue to the server. Do not use directly, call `shutdown()` instead.
Expand Down Expand Up @@ -2653,12 +2696,23 @@ def _shutdown_once(self, errors: list[Exception]) -> None:
)
self._flush_or_discard_queues(errors)

if self._metrics_autocapture is not None:
# Stop before the metrics flush and take one last sample, so the
# final window carries fresh readings instead of a blind spot.
self._run_lifecycle_cleanup(
"Failed to stop metrics autocapture on shutdown",
self._metrics_autocapture.stop,
errors,
)
if self._metrics is not None:
self._run_lifecycle_cleanup(
"Failed to flush metrics on shutdown", self._metrics.flush, errors
)
# _close (not reset): mark the metrics client terminal so a late
# autocapture sample that outran the join above can't re-seed the
# window and schedule a network flush after shutdown.
self._run_lifecycle_cleanup(
"Failed to reset metrics on shutdown", self._metrics.reset, errors
"Failed to close metrics on shutdown", self._metrics._close, errors
)
self._join_once(errors, flush_queues=False, lanes_prepared=True)
self._run_lifecycle_cleanup(
Expand Down
27 changes: 27 additions & 0 deletions posthog/metrics_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,11 @@ def __init__(self, client, config: Optional[dict] = None):
self._pid = os.getpid()
self._consecutive_send_failures = 0
self._capture_error_warned = False
# Terminal flag set by _close() at client shutdown. Once set, captures are
# dropped so a straggler (e.g. a runtime-metrics sample whose collector or
# before_send hook outran the autocapture join timeout) can't repopulate
# the cleared window or arm a post-shutdown flush timer.
self._closed = False
# Serializes flushes so a manual flush() can't race a timer flush for the same window.
self._flush_lock = threading.Lock()
self._series: dict = {}
Expand Down Expand Up @@ -329,6 +334,19 @@ def reset(self) -> None:
self._type_by_name = {}
self._type_collision_warned = set()

def _close(self) -> None:
"""Terminal shutdown: cancel the timer, drop the window, and refuse
further captures. Unlike reset() the instance does not accept new samples
afterwards, so a sampler thread that outran the shutdown join can't
schedule a post-shutdown flush by folding a late sample back in."""
with self._lock:
self._closed = True
self._clear_flush_timer()
self._series = {}
self._series_cap_warned = False
self._type_by_name = {}
self._type_collision_warned = set()

def _guarded_capture(
self,
metric_type: str,
Expand Down Expand Up @@ -416,6 +434,12 @@ def _capture(
with self._lock:
self._reset_after_fork_locked()

if self._closed:
# Shutdown already flushed and cleared the window. A late sample
# must not re-seed the window or arm a new flush timer, or it
# would fire a network request after the client shut down.
return

state = self._series.get(key)
if state is None:
if len(self._series) >= self._max_series_per_flush:
Expand Down Expand Up @@ -475,6 +499,9 @@ def _drop_inherited_window(self) -> None:
self._type_by_name = {}
self._type_collision_warned = set()
self._consecutive_send_failures = 0
# A forked child is a fresh process: even if the parent had shut its
# metrics client down, the child's captures must be live again.
self._closed = False

def _fold(self, state: _SeriesState, value: float) -> None:
if state.type == "count":
Expand Down
Loading