Skip to content

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

Merged
DanielVisca merged 5 commits into
mainfrom
posthog-code/metrics-python
Jul 15, 2026
Merged

feat(metrics): add posthog.metrics count/gauge/histogram API (alpha)#739
DanielVisca merged 5 commits into
mainfrom
posthog-code/metrics-python

Conversation

@DanielVisca

Copy link
Copy Markdown
Contributor

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 21:55:16 UTC
Duration: 338860ms

✅ 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 517ms
Endpoint And Method.Does Not Use Legacy Endpoints 1009ms
Required Headers.Has Authorization Bearer Header 1010ms
Required Headers.Has Content Type Json 1009ms
Required Headers.Has Posthog Sdk Info Format 1009ms
Required Headers.Has Posthog Attempt Header 1010ms
Required Headers.Has Posthog Request Id 1009ms
Required Headers.Has Posthog Request Timestamp 1008ms
Required Headers.Has User Agent 1009ms
Body Format.Body Has Created At And Batch 1010ms
Body Format.No Api Key In Body 1008ms
Body Format.No Sent At In Body 1009ms
Event Format.Event Has Required Root Fields 1009ms
Event Format.Event Uuid Is Valid 1008ms
Event Format.Event Timestamp Is Rfc3339 1008ms
Event Format.Distinct Id Is String 1008ms
Event Format.Distinct Id At Root Not Properties 1010ms
Event Format.Custom Properties Preserved 1008ms
Event Format.Set Properties Preserved 1009ms
Event Format.Set Once Properties Preserved 1008ms
Event Format.Groups Properties Preserved 1009ms
Event Format.Sdk Generates Uuid If Not Provided 1008ms
Event Format.Event Has Required Root Fields Batch 1012ms
Event Format.Event Uuid Is Valid Batch 1012ms
Event Format.Event Timestamp Is Rfc3339 Batch 1013ms
Event Format.Distinct Id Is String Batch 1012ms
Event Format.Distinct Id At Root Not Properties Batch 1013ms
Event Format.Custom Properties Preserved Batch 1012ms
Event Format.Set Properties Preserved Batch 1013ms
Event Format.Set Once Properties Preserved Batch 1014ms
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 1011ms
Deduplication.Generates Unique Uuids 1508ms
Deduplication.Different Events Same Content Different Uuids 1507ms
Deduplication.Preserves Uuid On Retry 7511ms
Deduplication.Preserves Timestamp On Retry 7517ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7512ms
Deduplication.No Duplicate Events In Batch 1503ms
Header Behavior On Retry.Attempt Header Starts At One 1009ms
Header Behavior On Retry.Attempt Header Increments On Retry 14516ms
Header Behavior On Retry.Request Id Preserved On Retry 7517ms
Header Behavior On Retry.Different Requests Have Different Request Ids 3515ms
Header Behavior On Retry.Request Timestamp Changes On Retry 7510ms
Response Format Validation.Success Response Has Uuid Keyed Results 1010ms
Response Format Validation.Success Response Has Ok For Each Event 1506ms
Response Format Validation.Success No Retry After When All Ok 1507ms
Response Format Validation.Success Retry After Present When Retry Events 2511ms
Response Format Validation.Success No Retry After When Drop Only 1507ms
Response Format Validation.Response Echoes Request Id 1009ms
Retry Behavior.Retries On 408 7515ms
Retry Behavior.Retries On 500 7512ms
Retry Behavior.Retries On 503 9512ms
Retry Behavior.Retries On 504 7516ms
Retry Behavior.Retryable Errors Have Retry After 4510ms
Retry Behavior.Respects Retry After On Retryable Error 12517ms
Retry Behavior.Does Not Retry On 400 3502ms
Retry Behavior.Does Not Retry On 401 3509ms
Retry Behavior.Does Not Retry On 402 3507ms
Retry Behavior.Does Not Retry On 413 3508ms
Retry Behavior.Does Not Retry On 415 3508ms
Retry Behavior.Non Retryable Errors Have No Retry After 3508ms
Retry Behavior.Implements Backoff 23521ms
Retry Behavior.Max Retries Respected 23534ms
Partial Batch Handling.Handles 200 Full Success 3000ms
Partial Batch Handling.Handles 200 With All Ok 4509ms
Partial Batch Handling.Does Not Retry Dropped Events 4510ms
Partial Batch Handling.Does Not Retry Limited Events 4508ms
Partial Batch Handling.Prunes Ok Events On Partial Retry 7511ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry 7512ms
Partial Batch Handling.Retries Only Retry Events From Partial 7517ms
Partial Batch Handling.Partial Retry Preserves Uuids 7511ms
Partial Batch Handling.Partial Retry Attempt Header Increments 7508ms
Partial Batch Handling.Partial Retry Request Id Preserved 7515ms
Partial Batch Handling.Respects Retry After On Partial 9513ms
Partial Batch Handling.Unknown Result Treated As Terminal 4506ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry 4509ms
Compression.Sends Gzip Content Encoding 1007ms
Compression.No Content Encoding When Disabled 1009ms
Compression.Compressed Body Is Decompressible 1009ms
Error Handling.Does Not Retry On Unknown 4Xx 3509ms
Event Options.Cookieless Mode Override 1008ms
Event Options.Disable Skew Correction Override 1009ms
Event Options.Process Person Profile Override 1009ms
Event Options.Product Tour Id Override 1008ms
Event Options.Unset Options Omitted 1008ms
Event Options.Options Override In Batch 1013ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties 1009ms
Geoip And Historical Migration.Historical Migration Set In Body 1008ms
Geoip And Historical Migration.Historical Migration Absent By Default 1009ms

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 1007ms
Request Payload.Flags Request Hits Flags Path Not Decide 1006ms
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 1006ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 1007ms
Request Payload.Disable Geoip Omitted Defaults To False 1006ms
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 1013ms
Request Lifecycle.Mock Response Value Is Returned To Caller 1001ms
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
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
Comment thread posthog/metrics_capture.py
Comment thread posthog/client.py
Comment thread posthog/client.py
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: 9606289a-ed0e-4b1b-828e-3b7f405d31e4
@DanielVisca DanielVisca force-pushed the posthog-code/metrics-python branch from 7bbb418 to 04d9d8b Compare July 14, 2026 21:49
@DanielVisca DanielVisca requested a review from dustinbyrne July 14, 2026 22:12

@dustinbyrne dustinbyrne left a comment

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.

Human-driven, agent-assisted follow-up review.

self.unit = unit
# Snapshot: the series key was computed from these values, so a caller
# mutating the dict after capture must not change the stored series.
self.attributes = dict(attributes) if attributes else None

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] Snapshot nested attribute values or enforce the scalar contract

_SeriesState only shallow-copies the attributes dictionary. Python currently treats nested values as supported: _series_key() explicitly handles lists/dicts, _to_otlp_any_value() encodes arrays, and the tests cover list attributes.

This lets the stored attributes diverge from the series key. For example:

  1. Record with attributes={"tags": ["a"]}.
  2. Mutate the list to ["a", "b"].
  3. Record again and flush.

The current payload has two points, both with tags=["a", "b"] and value 1, although the first series key was created for ["a"].

Browser and Node restrict metric attributes to scalar values, making their shallow copy sufficient. Please either deeply snapshot/normalize Python's supported nested values, or align Python with the other two SDKs by validating attributes as scalars and dropping nested values. Add a regression that mutates a nested value after the first capture and verifies stable wire attributes.

if outcome == "retry-later":
with self._lock:
self._consecutive_send_failures += 1
if self._consecutive_send_failures > _MAX_CONSECUTIVE_SEND_FAILURES:

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.

[P2 parity] Define a consistent bounded retry policy across metrics SDKs

Python retries at the fixed flush interval and drops the window on the fourth failure. Browser and Node also use a fixed metrics flush interval, but retain and retry the window forever; neither applies exponential backoff across failed metrics flushes. Node only adds fixed-delay transport retries within each attempt.

Retrying forever at a fixed cadence does not seem like the right behavior to copy. Can we choose an explicit cross-SDK policy—ideally capped exponential backoff plus a documented attempt or age limit—and pin it with tests? The shared JS logs implementation already uses exponential backoff capped at 64× the base interval and may be a useful precedent.

At minimum, Python's release note and implementation need reconciling: it says the window is dropped after three failed flushes, while this > 3 condition drops on the fourth.

Comment thread posthog/client.py
if self._metrics is None:
with self._metrics_lock:
if self._metrics is None:
self._metrics = PostHogMetrics(self, self._metrics_config)

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] Keep lazy metrics initialization inside the client's no-throw contract

Most public Client methods use @no_throw, but client.metrics constructs PostHogMetrics before the guarded recording path is reached. Because the nested config is typed only as dict, a type checker accepts:

Client("phc_...", metrics={"resource_attributes": "bad"})

and client.metrics.count(...) then raises AttributeError into application code.

Please give this path the same no-throw semantics as the rest of the client: validate nested configuration and fall back to defaults or a valid no-op metrics instance when initialization fails. Decorating the property with @no_throw() while returning None would not be sufficient, because the chained .count(...) would still throw. Add a regression asserting the complete public call does not raise.

@dustinbyrne dustinbyrne left a comment

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.

approving to unblock but there's some useful considerations in here

@DanielVisca DanielVisca merged commit 5e42b1e into main Jul 15, 2026
38 checks passed
@DanielVisca DanielVisca deleted the posthog-code/metrics-python branch July 15, 2026 15:40
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