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

Add opt-in, dependency-free macOS process, garbage collection, and host metrics with automatic process hostname attribution.
82 changes: 82 additions & 0 deletions _example_runtime_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Bounded CPU and memory workload for example.py."""

import gc
import hashlib
import os
import time
from threading import Event, Thread


_MIB = 1024 * 1024
_BLOCK_SIZE = 8 * _MIB


def _burn_cpu(stop: Event) -> None:
payload = b"x" * _MIB
# Hashing more than 2047 bytes releases the GIL, allowing parallel CPU work.
while not stop.is_set():
hashlib.sha256(payload).digest()


def _resize_memory(blocks: list[bytearray], target_mib: int) -> None:
target_blocks = target_mib * _MIB // _BLOCK_SIZE
del blocks[target_blocks:]
while len(blocks) < target_blocks:
block = bytearray(_BLOCK_SIZE)
block[::4096] = b"\x01" * (len(block) // 4096)
blocks.append(block)


def run_workload(duration: float = 30 * 60, phase_duration: float = 30) -> None:
cpu_count = getattr(os, "process_cpu_count", os.cpu_count)() or 1
max_workers = max(1, min(8, cpu_count // 2))
try:
physical_memory = os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE")
memory_mib = max(0, min(1024, physical_memory // 32 // _MIB))
except (AttributeError, OSError, ValueError):
memory_mib = 256
phases = [
("idle", 0, memory_mib // 8),
("ramp-up", 1, memory_mib // 4),
("CPU burst", max_workers, memory_mib // 4),
("CPU and memory peak", max_workers, memory_mib),
("recovery", max(1, max_workers // 2), memory_mib // 2),
("idle", 0, memory_mib // 8),
]
blocks: list[bytearray] = []
start = time.monotonic()
deadline = start + duration
phase_index = 0
try:
while time.monotonic() < deadline:
name, worker_count, target_mib = phases[phase_index % len(phases)]
phase_end = min(deadline, start + (phase_index + 1) * phase_duration)
_resize_memory(blocks, target_mib)
for _ in range(10000):
cycle: list[object] = []
cycle.append(cycle)
del cycle
gc.collect()
print(
f"Phase: {name}; CPU workers: {worker_count}; "
f"target memory: {target_mib} MiB; "
f"elapsed: {time.monotonic() - start:.0f}s / {duration:.0f}s",
flush=True,
)
stop = Event()
workers = []
try:
for _ in range(worker_count):
worker = Thread(target=_burn_cpu, args=(stop,), daemon=True)
worker.start()
workers.append(worker)
while time.monotonic() < phase_end:
time.sleep(min(1, max(0, phase_end - time.monotonic())))
finally:
stop.set()
for worker in workers:
worker.join()
phase_index += 1
finally:
blocks.clear()
gc.collect()
64 changes: 61 additions & 3 deletions example.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
# 2. Run this script and choose from the interactive menu

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

import posthog

Expand Down Expand Up @@ -71,8 +74,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, host, and GC metrics (30-minute workload, macOS)")
print("8. Exit")
choice = input("\nEnter your choice (1-8): ").strip()

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

elif choice == "7" and sys.platform == "darwin":
from _example_runtime_metrics import run_workload

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",
"cpu.logical_number",
"cpython.gc.generation",
"system.paging.direction",
"system.paging.state",
"system.memory.state",
"process.context_switch.type",
)
)
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)
print(
"Running for 30 minutes with 30-second phases. Press Ctrl+C to stop.",
flush=True,
)
try:
run_workload()
except KeyboardInterrupt:
print("\nMetrics workload stopped.", flush=True)
finally:
posthog.shutdown()

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