Skip to content

feat(metrics): add posthog.metrics count/gauge/histogram API (alpha)#739

Open
DanielVisca wants to merge 4 commits into
mainfrom
posthog-code/metrics-python
Open

feat(metrics): add posthog.metrics count/gauge/histogram API (alpha)#739
DanielVisca wants to merge 4 commits into
mainfrom
posthog-code/metrics-python

Conversation

@DanielVisca

Copy link
Copy Markdown

What this is

The Python leg of the posthog.metrics SDK surface (Metrics docs) — a faithful port of the posthog-js core client (posthog-js #4115, node wiring in #4117), so backend Python services can record metrics with zero OpenTelemetry setup:

client = Posthog("<ph_project_api_key>", metrics={"service_name": "billing-worker"})
client.metrics.count("invoices.processed", 1, attributes={"plan": "pro"})
client.metrics.gauge("queue.depth", 42)
client.metrics.histogram("job.duration", 187, unit="ms")

Design (mirrors posthog-js exactly, adapted to Python)

  • Statsd-style pre-aggregation: samples fold into per-series aggregates in memory; a burst of 10k count() calls is one data point on the wire. Delta temporality throughout, so restarts need no cross-window state.
  • Wire shape pinned to the ingest: OTLP/JSON ExportMetricsServiceRequest to {host}/i/v1/metrics?token=... — nano timestamps as decimal strings but histogram count/bucketCounts as plain JSON numbers (string-encoded u64s are silently dropped upstream, opentelemetry-rust#3328). Same DEFAULT_HISTOGRAM_BOUNDS as JS.
  • Thread-safe (unlike the JS single-threaded original): a lock around the window, a separate flush serializer, and a daemon threading.Timer armed on first capture (10s default). shutdown() flushes and resets.
  • Guardrails: max_series_per_flush cardinality cap (default 1000, warn once per window, existing series keep folding), type-collision warning, monotonic-counter validation, finite-value validation, before_send hook.
  • Failure handling: 5xx/429/network → merge the unsent window back and re-arm (no data loss on transient failures); 413 → drop with a warning; other 4xx → drop with an error log.
  • New file is posthog/metrics_capture.py (not metrics.py — that name would shadow a future module-level posthog.metrics accessor and matches the exception_capture.py house naming).

How did you test this code?

TDD — the test file was written first and observed failing at import, then all green:

  • New: posthog/test/test_metrics.py — 14 tests, each pinning a distinct contract: count burst → one delta+monotonic data point; gauge keeps last (and has no startTimeUnixNano, matching JS); the full histogram wire shape (the opentelemetry-rust#3328 encoding pin); series identity split by attributes but not by attribute order; OTLP value encoding (including the Python-specific bool-before-int trap — bool is an int subclass and would silently encode True as intValue: 1); cardinality cap drops new series but keeps folding existing ones; parameterized invalid-sample drops; disabled client no-ops; resource-attribute layering (user keys can't spoof telemetry.sdk.*); transient-failure merge-back (a 503'd window's counts sum into the next flush); shutdown flush.
  • Regression: posthog/test/test_client.py — 145 passed (I touched Client.__init__ and shutdown).
  • ruff check / ruff format clean.
  • Not done: check_public_api.py --write (griffe isn't installable in my env — PEP 668); CI should regenerate/check the snapshot, and the new Client.metrics property will need it. Also no live end-to-end send from this environment — the wire shape is byte-identical to posthog-js's, which was validated end-to-end against ingestion during the metrics alpha.

🤖 Agent context

Autonomy: Human-driven (agent-assisted) — Daniel directed this; assigned as DRI.

I (Claude) wrote this by porting packages/core/src/metrics/ from posthog-js (read in full: index.ts, metrics-utils.ts, config.ts, the value encoder in logs-utils.ts, and the _sendMetricsBatch transport for the URL/auth/outcome contract). Deliberate deviations from the JS original: flush_interval in seconds (Python client idiom, vs flushIntervalMs), thread-safety via locks (JS is single-threaded), no drainWindow() (browser-unload concern), and plain-requests transport with the same ok/too-large/retry-later/fatal outcome classification instead of the fetch retry stack. Part of the metrics-SDK track: js ✅ merged, node in review, python (this PR).

Port of the posthog-js core metrics client: statsd-style pre-aggregation
(counts sum, gauges keep last, histograms bucket with the OTel default
bounds), one OTLP/JSON data point per series per flush window, delta
temporality, posted to /i/v1/metrics with the project token. Thread-safe;
a daemon timer flushes every 10s and shutdown() drains the window. Includes
the cardinality guardrail (max_series_per_flush, default 1000), the
type-collision warning, transient-failure window merge-back, and the same
resource-attribute layering (SDK keys over user keys) as posthog-js.

Generated-By: PostHog Code
Task-Id: 0d65d94c-c18a-4d84-93f6-18eb54876551
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

posthog-python Compliance Report

Date: 2026-07-14 18:05:17 UTC
Duration: 338386ms

✅ All Tests Passed!

111/111 tests passed


Capture_V1 Tests

94/94 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint 518ms
Endpoint And Method.Does Not Use Legacy Endpoints 1011ms
Required Headers.Has Authorization Bearer Header 1008ms
Required Headers.Has Content Type Json 1010ms
Required Headers.Has Posthog Sdk Info Format 1009ms
Required Headers.Has Posthog Attempt Header 1009ms
Required Headers.Has Posthog Request Id 1009ms
Required Headers.Has Posthog Request Timestamp 1009ms
Required Headers.Has User Agent 1009ms
Body Format.Body Has Created At And Batch 1009ms
Body Format.No Api Key In Body 1010ms
Body Format.No Sent At In Body 1009ms
Event Format.Event Has Required Root Fields 1009ms
Event Format.Event Uuid Is Valid 1010ms
Event Format.Event Timestamp Is Rfc3339 1008ms
Event Format.Distinct Id Is String 1009ms
Event Format.Distinct Id At Root Not Properties 1009ms
Event Format.Custom Properties Preserved 1009ms
Event Format.Set Properties Preserved 1009ms
Event Format.Set Once Properties Preserved 1009ms
Event Format.Groups Properties Preserved 1008ms
Event Format.Sdk Generates Uuid If Not Provided 1009ms
Event Format.Event Has Required Root Fields Batch 1012ms
Event Format.Event Uuid Is Valid Batch 1013ms
Event Format.Event Timestamp Is Rfc3339 Batch 1012ms
Event Format.Distinct Id Is String Batch 1013ms
Event Format.Distinct Id At Root Not Properties Batch 1013ms
Event Format.Custom Properties Preserved Batch 1014ms
Event Format.Set Properties Preserved Batch 1014ms
Event Format.Set Once Properties Preserved Batch 1013ms
Event Format.Groups Properties Preserved Batch 1012ms
Event Format.Sdk Generates Uuid If Not Provided Batch 1013ms
Batch Behavior.Multiple Events In Single Batch 1507ms
Batch Behavior.Batch Envelope Smoke 1014ms
Batch Behavior.Flush With No Events Sends Nothing 1005ms
Batch Behavior.Flush At Triggers Batch 1508ms
Batch Behavior.Created At Reflects Batch Creation Time 511ms
Deduplication.Generates Unique Uuids 1507ms
Deduplication.Different Events Same Content Different Uuids 1507ms
Deduplication.Preserves Uuid On Retry 7516ms
Deduplication.Preserves Timestamp On Retry 7509ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7516ms
Deduplication.No Duplicate Events In Batch 1504ms
Header Behavior On Retry.Attempt Header Starts At One 1009ms
Header Behavior On Retry.Attempt Header Increments On Retry 14523ms
Header Behavior On Retry.Request Id Preserved On Retry 7511ms
Header Behavior On Retry.Different Requests Have Different Request Ids 3515ms
Header Behavior On Retry.Request Timestamp Changes On Retry 7515ms
Response Format Validation.Success Response Has Uuid Keyed Results 1006ms
Response Format Validation.Success Response Has Ok For Each Event 1507ms
Response Format Validation.Success No Retry After When All Ok 1508ms
Response Format Validation.Success Retry After Present When Retry Events 2510ms
Response Format Validation.Success No Retry After When Drop Only 1508ms
Response Format Validation.Response Echoes Request Id 1009ms
Retry Behavior.Retries On 408 7516ms
Retry Behavior.Retries On 500 7508ms
Retry Behavior.Retries On 503 9516ms
Retry Behavior.Retries On 504 7517ms
Retry Behavior.Retryable Errors Have Retry After 4509ms
Retry Behavior.Respects Retry After On Retryable Error 12518ms
Retry Behavior.Does Not Retry On 400 3504ms
Retry Behavior.Does Not Retry On 401 3508ms
Retry Behavior.Does Not Retry On 402 3507ms
Retry Behavior.Does Not Retry On 413 3508ms
Retry Behavior.Does Not Retry On 415 3509ms
Retry Behavior.Non Retryable Errors Have No Retry After 3510ms
Retry Behavior.Implements Backoff 23531ms
Retry Behavior.Max Retries Respected 23523ms
Partial Batch Handling.Handles 200 Full Success 3002ms
Partial Batch Handling.Handles 200 With All Ok 4509ms
Partial Batch Handling.Does Not Retry Dropped Events 4509ms
Partial Batch Handling.Does Not Retry Limited Events 4509ms
Partial Batch Handling.Prunes Ok Events On Partial Retry 7513ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry 7511ms
Partial Batch Handling.Retries Only Retry Events From Partial 7514ms
Partial Batch Handling.Partial Retry Preserves Uuids 7508ms
Partial Batch Handling.Partial Retry Attempt Header Increments 7513ms
Partial Batch Handling.Partial Retry Request Id Preserved 7516ms
Partial Batch Handling.Respects Retry After On Partial 9509ms
Partial Batch Handling.Unknown Result Treated As Terminal 4508ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry 4508ms
Compression.Sends Gzip Content Encoding 1010ms
Compression.No Content Encoding When Disabled 1010ms
Compression.Compressed Body Is Decompressible 1009ms
Error Handling.Does Not Retry On Unknown 4Xx 3507ms
Event Options.Cookieless Mode Override 1010ms
Event Options.Disable Skew Correction Override 1009ms
Event Options.Process Person Profile Override 1009ms
Event Options.Product Tour Id Override 1009ms
Event Options.Unset Options Omitted 1008ms
Event Options.Options Override In Batch 1012ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties 1008ms
Geoip And Historical Migration.Historical Migration Set In Body 1009ms
Geoip And Historical Migration.Historical Migration Absent By Default 1010ms

Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 1007ms
Request Payload.Flags Request Uses V2 Query Param 1006ms
Request Payload.Flags Request Hits Flags Path Not Decide 1007ms
Request Payload.Flags Request Omits Authorization Header 1007ms
Request Payload.Token In Flags Body Matches Init 1007ms
Request Payload.Groups Round Trip 1007ms
Request Payload.Groups Default To Empty Object 1007ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 1007ms
Request Payload.Disable Geoip Omitted Defaults To False 1007ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 1007ms
Request Lifecycle.No Flags Request On Init Alone 503ms
Request Lifecycle.No Flags Request On Normal Capture 1507ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 1011ms
Request Lifecycle.Mock Response Value Is Returned To Caller 1003ms
Retry Behavior.Retries Flags On 502 1007ms
Retry Behavior.Retries Flags On 504 1007ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 1509ms

Crash safety (a telemetry SDK must never raise into the host app): the series
key is now JSON-based like the JS core's seriesKey, so list/dict attribute
values and mixed-type keys aggregate instead of raising TypeError; before_send
returns are validated (non-dict dropped, unknown metric types dropped instead
of folding as histograms); and a catch-all guard drops-with-warning anything
that still escapes.

Fork safety: a forked child inherits the parent's window and a timer handle
whose thread doesn't exist in the child — captures now detect the PID change,
drop the inherited window (avoiding duplication), and re-arm.

Failure handling: retry-later flushes log a warning and are bounded by a
3-consecutive-failure budget (then dropped loudly), merge-back respects the
series cap so an outage with attribute churn can't grow memory without bound,
and the send goes through the shared pooled session with a gzipped body and a
URL-encoded token.

Encoding parity with posthog-js: non-finite floats emit proto3 literals
(Infinity/-Infinity/NaN), integral floats emit intValue, None-valued
attributes are stripped before keying, and bool/int values stay distinct
series via the JSON key.

Generated-By: PostHog Code
Task-Id: 0d65d94c-c18a-4d84-93f6-18eb54876551
@DanielVisca DanielVisca marked this pull request as ready for review July 14, 2026 17:32
@DanielVisca DanielVisca requested a review from a team as a code owner July 14, 2026 17:32
@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "fix(metrics): harden the capture path an..." | Re-trigger Greptile

Comment thread posthog/metrics_capture.py Outdated

def _to_otlp_key_value_list(attributes: dict) -> list:
return [
{"key": key, "value": _to_otlp_any_value(value)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Attribute Keys Stay Numeric

When callers pass non-string attribute keys, _series_key() accepts them by converting keys with str(k), but the OTLP payload still emits the original key object here. The tested input attributes={"a": 1, 2: "x"} can therefore send key: 2 even though OTLP KeyValue.key is a string field, causing strict decoding to reject the metric or drop the attribute.

Suggested change
{"key": key, "value": _to_otlp_any_value(value)}
{"key": str(key), "value": _to_otlp_any_value(value)}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in be5e767 — keys are now str()d at the wire boundary too, matching the series identity. Regression test added (test_non_string_attribute_keys_stringify_on_the_wire) asserting {"a": 1, 2: "x"} emits keys {"a", "2"} as strings.

Typed extraction after the before_send isinstance narrow (dict[str, Any] +
closed defaults the validation below drops), RequestException added to the
vendored requests stub as the base the real hierarchy has, and the public
API snapshot regenerated for the new Client.metrics surface.

Generated-By: PostHog Code
Task-Id: 0d65d94c-c18a-4d84-93f6-18eb54876551
Series identity already str()s keys, but the payload emitted the raw object —
a numeric key violates OTLP's string KeyValue.key and strict decoders reject
or drop the attribute.

Generated-By: PostHog Code
Task-Id: 0d65d94c-c18a-4d84-93f6-18eb54876551
@DanielVisca DanielVisca enabled auto-merge (squash) July 14, 2026 18:18
unit: Optional[str],
attributes: Optional[dict],
) -> None:
if getattr(self._client, "disabled", False):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] send=False still transmits metrics

Client.__init__ documents send=False as queueing captures without sending them (posthog/client.py:287), and the event path returns before transport when self.send is false. The metrics gate here checks only disabled, so the new capture surface bypasses that established transport switch.

For example:

client = Client("phc_test", send=False, thread=0)
client.metrics.count("jobs.processed")
client.metrics.flush()

The current code produces:

HTTP POST calls: 1

Expected:

HTTP POST calls: 0

This causes unexpected telemetry egress in environments and tests that deliberately construct a no-send client.

Please make metrics capture/flush honor client.send, or explicitly document why metrics intentionally differ from event capture.

A regression test should cover both manual flush and timer flush with send=False and assert that _get_session().post is never called.

Comment thread posthog/client.py
self.disable_geoip = disable_geoip
self._metrics_config = metrics
self._metrics: Optional[PostHogMetrics] = None
self._metrics_lock = threading.Lock()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Reinitialize the new metrics locks after fork() to avoid a child deadlock

Client._reinit_after_fork() already replaces thread-owned queue/session/cache state because locks held by vanished parent threads remain locked in the child. The new _metrics_lock here, plus PostHogMetrics._lock and _flush_lock, are not replaced. The metrics PID guard cannot recover because _capture() acquires _lock before calling _reset_after_fork_locked().

A concrete timeline is:

  1. A parent thread holds client.metrics._lock.
  2. The process forks.
  3. The child calls client.metrics.count("after.fork").
  4. The child blocks forever on the inherited lock.

The current reproduction exited via a two-second alarm:

fork_child_exit=42

Expected: the child records the metric without blocking and uses fresh metrics state.

Please replace _metrics_lock and any instantiated metrics locks/timer/window in the existing at-fork callback, without acquiring the inherited locks.

A regression test should hold each metrics lock from another thread, call os.fork(), invoke the public metrics.count() path in the child, and assert that the child exits normally within a short deadline.

Comment thread posthog/client.py
self.flush(timeout_seconds=None)
if self._metrics is not None:
try:
self._metrics.flush()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] The public module-level posthog.shutdown() leaves metrics buffered

This adds metrics flushing to direct Client.shutdown(), but the documented module helper in posthog/__init__.py:1122 still calls only _proxy("flush") and _proxy("join"). A default client obtained through posthog.setup() therefore bypasses this new lifecycle path.

For example:

client = posthog.setup()
client.metrics.count("lost.on.global.shutdown")
posthog.shutdown()

The current code produces:

metric HTTP calls: 0
buffered series after shutdown: 1

Expected:

metric HTTP calls: 1
buffered series after shutdown: 0

This loses the final metrics window for applications using the global SDK lifecycle.

Please make module-level shutdown() delegate to the client's shutdown() implementation rather than separately calling flush() and join().

A regression test should record through posthog.setup().metrics, call the public posthog.shutdown(), and assert that the metrics transport ran and the window was cleared.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants