Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .sampo/changesets/macos-runtime-metrics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
pypi/posthog: minor
---

Add opt-in, dependency-free macOS runtime metrics with `metrics={"capture_runtime_metrics": True}`. Process metrics follow OpenTelemetry conventions: cumulative `process.cpu.time` counters and CPU-normalized `process.cpu.utilization` gauges distinguished by `cpu.mode`, plus current RSS (`process.memory.usage`) and native threads (`process.thread.count`) as cumulative non-monotonic sums. `process.memory.utilization` reports RSS divided by total physical RAM as a gauge fraction, not utilization against a container limit. CPython GC collections, collected objects, and uncollectable objects use cumulative `gc.get_stats()` counters distinguished by `cpython.gc.generation`, not allocation bookkeeping or heap-size measurements.

Retain custom `app.process.cpu.percent` (100% means one busy core) and cumulative `app.process.cpu.ctx_switch.total` (the native reader provides a combined count, not OpenTelemetry's required voluntary/involuntary split). Runtime sums preserve the latest observation and a stable start time across flushes and retries; manual metrics retain their existing wire format and delta aggregation. Forked workers receive independent identities and counter baselines. `shutdown()` collects a final sample, flushes, and stops collection. Unsupported platforms warn and do not collect; collection remains disabled by default.

Separately enable dependency-free macOS host metrics with `capture_host_metrics=True`: aggregate CPU busy fraction, explicitly estimated available memory (free + inactive), native categorical memory pressure, used swap, and page-accounted compressed swap transfer bytes. Only swap usage uses a standard OpenTelemetry instrument; the other host instruments use custom `app.system.*` names. Pressure readings are best-effort and omitted when unavailable. Enable a single collector per host to avoid duplicates; inherited clients do not restart host collection after fork.
66 changes: 63 additions & 3 deletions example.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
# 2. Run this script and choose from the interactive menu

import os
import sys
import time
from datetime import datetime, timezone
from uuid import uuid4

import posthog

Expand Down Expand Up @@ -71,8 +75,9 @@ def load_env_file():
print(f"4. Flag dependencies examples{local_eval_note}")
print("5. Context management and tagging examples")
print("6. Run all examples")
print("7. Exit")
choice = input("\nEnter your choice (1-7): ").strip()
print("7. Process CPU and memory metrics (60 seconds, macOS)")
print("8. Exit")
choice = input("\nEnter your choice (1-8): ").strip()

if choice == "1":
print("\n" + "=" * 60)
Expand Down Expand Up @@ -487,13 +492,68 @@ def process_payment(payment_id):
posthog.capture("demo_completed")
print("✅ Demo completed with context tags")

elif choice == "7" and sys.platform == "darwin":
run_id = uuid4().hex

def print_runtime_sample(sample):
labels = ", ".join(
f"{key}={value}"
for key, value in sample["attributes"].items()
if key
in (
"cpu.mode",
"cpython.gc.generation",
"system.paging.direction",
"system.paging.state",
)
)
name = sample["name"] + (f"{{{labels}}}" if labels else "")
print(
f"{datetime.now(timezone.utc).isoformat()} "
f"{name} = {sample['value']:.2f} {sample['unit']}",
flush=True,
)
return sample

posthog.metrics = {
"capture_runtime_metrics": True,
"capture_host_metrics": True,
"service_name": "python-runtime-metrics-demo",
"flush_interval": 5,
"resource_attributes": {"demo.run_id": run_id},
"before_send": print_runtime_sample,
}
posthog.setup()
print(f"Service: python-runtime-metrics-demo; demo.run_id: {run_id}", flush=True)
start = time.monotonic()
allocation = None
for phase, end, busy in [
("idle", 20, False),
("CPU busy", 40, True),
("memory allocated (+64 MiB)", 60, False),
]:
if end == 60:
allocation = bytearray(64 * 1024 * 1024)
allocation[::4096] = b"\x01" * (len(allocation) // 4096)
print(f"{datetime.now(timezone.utc).isoformat()} Phase: {phase}", flush=True)
while time.monotonic() < start + end:
if busy:
sum(i * i for i in range(10000))
else:
time.sleep(min(0.1, max(0, start + end - time.monotonic())))

elif choice == "7":
print("Automatic runtime metrics are currently supported only on macOS.")
posthog.shutdown()
exit(1)

elif choice == "8":
print("👋 Goodbye!")
posthog.shutdown()
exit()

else:
print("❌ Invalid choice. Please run again and select 1-7.")
print("❌ Invalid choice. Please run again and select 1-8.")
posthog.shutdown()
exit()

Expand Down
11 changes: 8 additions & 3 deletions posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,9 +339,13 @@ 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``, ...).
``capture_runtime_metrics=True`` automatically collects process CPU,
RSS memory and its fraction of total physical RAM, native thread count,
CPython GC statistics, and total context switches on macOS; disabled by
default. Applied when
``setup()`` builds the global client, or on a later ``setup()`` call
if the metrics API hasn't been used yet.
traces: Config dict for distributed tracing: ``service_name``,
``service_version``, ``environment``, ``resource_attributes``,
``flush_interval`` (5 s), ``max_queue_size`` (2048),
Expand Down Expand Up @@ -1444,6 +1448,7 @@ def setup() -> Client:
# already forced setup()) still applies until the metrics API is first used.
if default_client._metrics is None:
default_client._metrics_config = metrics
default_client._start_runtime_metrics()
# traces=None means off, so the module option applies only where the client
# has none; False is the latch of an init that failed and stays off.
if traces is not None and default_client._traces_config is None:
Expand Down
Loading
Loading