Skip to content

feat(metrics): metrics autocapture for HTTP, database and runtime metrics - #992

Open
DanielVisca wants to merge 9 commits into
mainfrom
posthog/otel-metrics-autocapture
Open

DanielVisca wants to merge 9 commits into
mainfrom
posthog/otel-metrics-autocapture

Conversation

@DanielVisca

@DanielVisca DanielVisca commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Metrics needs hand-written client.metrics.* calls today. We want metrics to be the easiest setup: install the SDK, set one flag, and get HTTP, database and runtime metrics.

Why: #967 was closed because a hand-rolled HTTP wrapper duplicates OpenTelemetry instrumentation, and PostHog accepts OTLP directly (#914 took the hand-rolled route for runtime metrics). So this change starts the official OpenTelemetry instrumentors for you instead of writing new ones. Companion to PostHog/posthog-js#5149, with the same config shape.

posthog = Posthog("phc_...", metrics={"autocapture": True, "service_name": "billing-api"})
# or {"autocapture": {"http": True, "db": True, "runtime": False}}
  • New posthog[metrics] extra (OpenTelemetry SDK 1.39+, instrumentation 0.60b0+: 0.58b0 has the runtime metric names, and 0.60b0 is the first in which the httpx instrumentor excludes URLs). opentelemetry-bootstrap -a install adds the instrumentors for the installed libraries; autocapture finds them through the opentelemetry_instrumentor entry points, as opentelemetry-instrument does.
  • HTTP: requests, urllib, urllib3, httpx, aiohttp, Django, Flask, FastAPI, Starlette, and more, with stable semantic convention names (http.server.request.duration, seconds), the same names posthog-node sends. Requests to the PostHog host are excluded (any case, with or without the default port), and the app's OTEL_PYTHON_*_EXCLUDED_URLS are kept. HTTP instrumentors get no tracer, and the instrumentors autocapture starts do not propagate context, so app requests carry no trace context or baggage.
  • DB: a span processor turns driver-level client spans (psycopg2, psycopg, asyncpg, pymysql, sqlite3, pymongo, redis, ...) into db.client.operation.duration. SQLAlchemy spans are not counted, because they wrap a driver span. Only bounded attributes; never the statement. On all metrics, raw URLs and paths, client addresses, user agents and captured headers are never recorded (routes are).
  • Runtime: process CPU, memory, threads, file descriptors and cpython.gc.* from system_metrics (process-level only, no host-wide system.*).
  • Private meter and tracer providers and exporter; nothing is set on the OpenTelemetry globals, and the app's OTEL_EXPORTER_OTLP_* settings are not used. If the app already has OpenTelemetry, it warns and does not start. Nothing is recorded while the client is disabled or send=False. metrics.before_send applies to autocaptured measurements (value and attributes, or drop). flush() exports the current window; shutdown() exports the last window and uninstruments. At exit, the last window is sent within the client's exit budget.
  • Resource has telemetry.distro.name=posthog-python so we can measure adoption.
  • Create the client before the web framework loads (Django: AppConfig.ready(); Flask: before Flask(...)). Django is instrumented only when its settings are configured. On shutdown, a Flask app whose WSGI app was wrapped later (for example by auth middleware) keeps its inert instrumentation, so the middleware stays. A later module-level setup() with metrics={"autocapture": ...} also starts it.
  • The module is private (posthog/_metrics_autocapture.py); metrics={"autocapture": ...} is the only public surface.

💚 How did you test it?

  • TDD: posthog/test/test_metrics_autocapture.py (65 tests; each review fix has a test that failed before it). ruff, mypy (baseline) and python -W error -c "import posthog" clean. Full suite: 5683 passed, 0 failed. test_async_traces in test/ai/langchain/test_callbacks.py now checks the concurrent traces without assuming their order.
  • End to end against a local PostHog stack (hogli with the metrics intent): a Flask + SQLAlchemy/psycopg2 + redis + requests app. In ClickHouse I saw http.server.request.duration (with http.route), http.client.request.duration, db.client.operation.duration (postgresql and redis), process.* and cpython.gc.*. No series for the PostHog host.
  • Minimum versions: on Python 3.10 with OpenTelemetry SDK 1.39.0 and instrumentation 0.60b0, all autocapture tests pass. On ee2a9d9, urllib, urllib3, requests, httpx, aiohttp-client and FastAPI recorded metrics with status codes (and http.route for FastAPI) and sent no traceparent or baggage, also with active baggage. On 7a10866, a real Tornado server and client exported no raw URL, token or email (the server metric keeps http.route).

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed. (Follow-up: metrics docs "Autocapture" section.)
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran sampo add to generate a changeset file

🤖 Agent context

🤖 Robot review: the review fixes after the rebase (commits c5a7cd4, e543161, 2ec7b27, 7621c33, ee2a9d9 and 7a10866), the updates to this description, and the replies on the review threads were written and posted by the PostHog Slack app (an AI agent) from @DanielVisca's account. They were not individually reviewed by a human before posting.

Autonomy: Human-driven (agent-assisted)

  • Written with Claude Code in PostHog Desktop. The user chose the OpenTelemetry-native approach, Node and Python together, and opt-in by default.
  • The sqlite3 instrumentor wraps cursors only, not the Connection.execute shortcut; drivers and SQLAlchemy use cursors, so the tests use cursors.

Created with PostHog Desktop

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

posthog-python Compliance Report

Date: 2026-10-27T21:58:09.916612+00:00
Duration: 259934ms

✅ All Tests Passed!

121/121 tests passed


Capture_V1 Tests

✅ 95/95 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 518ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 513ms
Required Headers.Has Authorization Bearer Header ✅ 511ms
Required Headers.Has Content Type Json ✅ 511ms
Required Headers.Has Posthog Sdk Info Format ✅ 512ms
Required Headers.Has Posthog Attempt Header ✅ 511ms
Required Headers.Has Posthog Request Id ✅ 512ms
Required Headers.Has Posthog Request Timestamp ✅ 511ms
Required Headers.Has User Agent ✅ 511ms
Body Format.Body Has Created At And Batch ✅ 512ms
Body Format.No Api Key In Body ✅ 510ms
Body Format.No Sent At In Body ✅ 512ms
Event Format.Event Has Required Root Fields ✅ 511ms
Event Format.Event Uuid Is Valid ✅ 511ms
Event Format.Event Timestamp Is Rfc3339 ✅ 512ms
Event Format.Non Utc Event Timestamp Is Converted To Utc ✅ 524ms
Event Format.Distinct Id Is String ✅ 511ms
Event Format.Distinct Id At Root Not Properties ✅ 511ms
Event Format.Custom Properties Preserved ✅ 511ms
Event Format.Set Properties Preserved ✅ 511ms
Event Format.Set Once Properties Preserved ✅ 511ms
Event Format.Groups Properties Preserved ✅ 512ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 511ms
Event Format.Event Has Required Root Fields Batch ✅ 515ms
Event Format.Event Uuid Is Valid Batch ✅ 516ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 515ms
Event Format.Distinct Id Is String Batch ✅ 515ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 515ms
Event Format.Custom Properties Preserved Batch ✅ 516ms
Event Format.Set Properties Preserved Batch ✅ 515ms
Event Format.Set Once Properties Preserved Batch ✅ 514ms
Event Format.Groups Properties Preserved Batch ✅ 515ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 515ms
Batch Behavior.Multiple Events In Single Batch ✅ 520ms
Batch Behavior.Batch Envelope Smoke ✅ 517ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 507ms
Batch Behavior.Flush At Triggers Batch ✅ 1013ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 512ms
Deduplication.Generates Unique Uuids ✅ 519ms
Deduplication.Different Events Same Content Different Uuids ✅ 514ms
Deduplication.Preserves Uuid On Retry ✅ 6520ms
Deduplication.Preserves Timestamp On Retry ✅ 6517ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 6525ms
Deduplication.No Duplicate Events In Batch ✅ 520ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 512ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 13529ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 6516ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 3022ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 6521ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 511ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 517ms
Response Format Validation.Success No Retry After When All Ok ✅ 514ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 1517ms
Response Format Validation.Success No Retry After When Drop Only ✅ 514ms
Response Format Validation.Response Echoes Request Id ✅ 511ms
Retry Behavior.Retries On 408 ✅ 6521ms
Retry Behavior.Retries On 500 ✅ 6522ms
Retry Behavior.Retries On 503 ✅ 8524ms
Retry Behavior.Retries On 504 ✅ 6521ms
Retry Behavior.Retryable Errors Have Retry After ✅ 3519ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 11518ms
Retry Behavior.Does Not Retry On 400 ✅ 2516ms
Retry Behavior.Does Not Retry On 401 ✅ 2513ms
Retry Behavior.Does Not Retry On 402 ✅ 2515ms
Retry Behavior.Does Not Retry On 413 ✅ 2514ms
Retry Behavior.Does Not Retry On 415 ✅ 2515ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2515ms
Retry Behavior.Implements Backoff ✅ 22537ms
Retry Behavior.Max Retries Respected ✅ 22527ms
Partial Batch Handling.Handles 200 Full Success ✅ 2512ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3518ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3518ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 3518ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 6524ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 6518ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 6521ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 6519ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 6524ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 6522ms
Partial Batch Handling.Respects Retry After On Partial ✅ 8522ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3517ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 3519ms
Compression.Sends Gzip Content Encoding ✅ 512ms
Compression.No Content Encoding When Disabled ✅ 511ms
Compression.Compressed Body Is Decompressible ✅ 511ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2514ms
Event Options.Cookieless Mode Override ✅ 512ms
Event Options.Disable Skew Correction Override ✅ 511ms
Event Options.Process Person Profile Override ✅ 511ms
Event Options.Product Tour Id Override ✅ 512ms
Event Options.Unset Options Omitted ✅ 511ms
Event Options.Options Override In Batch ✅ 515ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 511ms
Geoip And Historical Migration.Historical Migration Set In Body ✅ 511ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 512ms

Capture_Ai Tests

✅ 5/5 tests passed

View Details
Test Status Duration
Routing.Capture Ai Posts To Ai Endpoint ✅ 511ms
Routing.Capture Does Not Reroute Ai Named Events ✅ 512ms
Identity.Capture Ai Event Has Uuid ✅ 511ms
Identity.Capture Ai Keeps Supplied Uuid ✅ 511ms
Timestamp Format.Non Utc Event Timestamp Is Converted To Utc ✅ 512ms

Feature_Flags Tests

✅ 17/17 tests passed

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

Feature_Flags_Local_Evaluation Tests

✅ 4/4 tests passed

View Details
Test Status Duration
Versioned Boolean Matching.Matching Version Missing ✅ 75ms
Versioned Boolean Matching.Matching Version 1 ✅ 71ms
Versioned Boolean Matching.Matching Version 2 ✅ 71ms
Versioned Boolean Matching.Version Only Reload 1 2 1 2 Missing ✅ 38ms

@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Retrigger

[Medium risk] Adds metrics collection for HTTP, database, and runtime operations.

The PR is not yet safe to merge because autocapture remains unusable in workers forked after initialization.

Reviews (2) · Last reviewed commit: "Merge branch 'main' into posthog/otel-me..."

Comment thread posthog/_metrics_autocapture.py
Comment thread posthog/_metrics_autocapture.py
Comment thread posthog/metrics_autocapture.py Outdated

Copy link
Copy Markdown
Contributor Author

Updated this branch from main with a signed merge. This brings in the MCP public API snapshot entry that was missing from the CI merge result. Observed checks now pass locally: make public_api_check, the snapshot script tests, and all 13 metrics autocapture tests. The PR stays in draft for the SDK API and documentation review.

Copy link
Copy Markdown
Contributor Author

CI is now green on the updated head, including the Public API snapshot check, Python test jobs, and SDK compliance checks. This remains draft because the new autocapture API and its documentation still need review.

@DanielVisca
DanielVisca marked this pull request as ready for review October 6, 2026 16:40
@DanielVisca
DanielVisca requested a review from a team as a code owner October 6, 2026 16:40

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not approved yet — waiting on the conditions below.

Re-add the stamphog label to request another review once you have addressed this.

stamphog can't auto-approve this pull request, and the refusal is final. Two gates refused it. The deny-list gate matched deps_toolchain, because pyproject.toml and uv.lock add a new metrics extra and many OpenTelemetry packages. It also matched public_api, because references/public_api_snapshot.txt changed. The tier gate classified the change as T2-never (1094 lines, 8 files, cross-cutting feature), a class that is never auto-approved.

The size gate passed, so splitting the PR won't clear the deny-list match on its own. The next step is to ask a human reviewer to look at it. If you'd like it reviewed in smaller pieces, you could separate the dependency and public API changes from the rest and ask a human to review each.

  • 👍 on the PR from greptile-apps[bot].
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✗ matches: deps_toolchain, public_api
size ✓ 493L, 3F substantive, 1094L/8F incl. docs/generated/snapshots — within ceiling
tier ✗ classified as T2-never: T2-never (1094L, 8F, cross-cutting, feat)
stamphog 2.3.1 .stamphog/policy.yml @ unknown · reviewed head 3bfa7e1

Comment thread posthog/client.py Outdated
Comment thread posthog/_metrics_autocapture.py
@veria-ai

veria-ai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 4 · PR risk: 0/10

@github-actions

Copy link
Copy Markdown
Contributor

This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the stale label – otherwise this will be closed in another week.

@github-actions github-actions Bot added the stale label Oct 16, 2026

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Automated advisory code review.

Comment thread posthog/metrics_autocapture.py Outdated
try:
instrumentor_class = entry_point.load()
instrumentor = (
instrumentor_class(config=_RUNTIME_CONFIG)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

blocking: Do not reinitialize an app-owned runtime instrumentor before checking ownership — when an application already instruments system metrics using a private meter provider, this constructor resets the singleton's configuration before the ownership check skips it, breaking existing host callbacks. Check ownership without reinitializing that singleton. Reproduction: reproduced — test_existing_private_runtime_instrumentation_is_not_reconfigured failed on the originally reviewed head after a previously working system.cpu.time metric disappeared with KeyError: 'system.cpu.time'.

@DanielVisca DanielVisca Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Fixed in c5a7cd4. Autocapture now checks the instrumentor singleton before it creates one, so it does not run __init__ again on an instance that the app owns. Test: test_does_not_reconfigure_runtime_metrics_that_the_app_set_up (before the fix it failed with KeyError: 'system.cpu.time').

with _lock:
if _active is not self:
return
_active = None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

blocking: Keep the singleton reserved until uninstrumentation finishes — clearing _active before removing instrumentation lets a concurrent replacement client start and skip the still-installed instrumentors; the old shutdown then removes them, leaving the replacement without HTTP metrics. Coordinate startup ownership through completion of uninstrumentation. Reproduction: reproduced — test_concurrent_shutdown_restart_never_returns_uninstrumented_handle failed on the originally reviewed head because a replacement handle was returned but a subsequent successful HTTP request produced no duration metric.

@DanielVisca DanielVisca Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Fixed in c5a7cd4. shutdown() now uninstruments while it holds the module lock, and clears _active after that. A client that starts at the same time waits, then instruments the libraries again. The last export still runs outside the lock. Test: test_a_client_that_starts_during_shutdown_records_http_metrics. In 2ec7b27 the test uses a lock that reports when a thread waits, so the result does not depend on timing.

@marandaneto marandaneto removed the stale label Oct 21, 2026
@marandaneto
marandaneto requested a review from a team October 21, 2026 12:53

Copy link
Copy Markdown
Member

@DanielVisca Do you intend to address the review comments on this PR?

@posthog posthog Bot 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.

PostHog Review · Standard · reviewed 7ab06e5: 2 Must fix, 7 Should fix.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

Comment thread posthog/client.py
Comment on lines +1153 to +1155
is_enabled=lambda: (client := weak_self()) is not None
and not client.disabled
and client.send,

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 · Gate metric collection while the client is disabled

When an initialized client sets disabled=True, instrumentors continue recording because is_enabled gates only export. Re-enabling before the next collection exports HTTP and database measurements from the disabled period, violating the existing disabled-capture contract. Apply the gate when recording measurements, rather than only when exporting.

@DanielVisca DanielVisca Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Fixed in 2ec7b27. The instruments now record only while the client is enabled and send is on, like client.metrics, so the disabled period is never exported. Runtime (observable) callbacks return nothing while the client is disabled. Test: test_records_nothing_while_disabled (HTTP and database).

Comment thread posthog/client.py Outdated
Comment on lines +1152 to +1156
resource_attributes=resource_attributes,
is_enabled=lambda: (client := weak_self()) is not None
and not client.disabled
and client.send,
export_interval_seconds=metrics_config.get("flush_interval"),

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 · Honor the existing metrics filter for autocaptured data

With metrics={"autocapture": True, "before_send": scrub}, this helper omits the existing metrics filter. Autocaptured HTTP and database attributes export directly through OTLP without calling scrub, bypassing the application's metric redaction and drop rules. Forward the hook and apply it to autocaptured measurements before aggregation.

@DanielVisca DanielVisca Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Fixed in 2ec7b27. metrics.before_send now runs on each autocaptured measurement before aggregation, and on runtime observations. It gets the client.metrics sample shape and can change value and attributes, or drop the sample. The name, type and unit of an OpenTelemetry instrument are fixed, so changes to them are ignored. A hook that raises drops the measurement and logs an error, as in client.metrics. Tests: test_applies_before_send_to_measurements, test_drops_measurements_when_before_send_raises, test_applies_before_send_to_runtime_metrics.

Comment on lines +332 to +337
def _reinit_after_fork(self) -> None:
# The instrumentation and the providers stay active in the child, and the
# OpenTelemetry metric reader restarts its export thread there. The
# runtime instrumentor keeps the process it was created in, so it would
# report the parent process.
for instrumentor in self._instrumentors:

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 · Reset the metric reader's inherited export lock after fork

When a process forks during a periodic export, the child inherits the reader's locked _export_lock. OpenTelemetry 1.40 restarts its thread without resetting that lock, while _reinit_after_fork() only refreshes psutil. Child exports and shutdown() then block indefinitely; reset the reader's inherited synchronization state or recreate it.

@DanielVisca DanielVisca Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Fixed in 2ec7b27. The child now replaces the reader's _export_lock. Test: test_exports_from_a_child_forked_during_an_export holds the lock in another thread at fork time. Without the fix, the child's flush blocks until the test stops it.

Comment thread posthog/metrics_autocapture.py Outdated
Comment on lines +579 to +582
if entry_point.name in _HTTP_CLIENT_INSTRUMENTORS:
# PostHog's own uploads, including this exporter's, are not app traffic.
kwargs["excluded_urls"] = excluded_urls
instrumentor.instrument(**kwargs)

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 · Apply PostHog URL exclusions through supported instrumentor APIs

With HTTPX or aiohttp-client instrumentation installed, requests to the configured PostHog host still produce HTTP metrics. Their OpenTelemetry 0.61b0 instrumentors read exclusions from environment configuration and ignore this excluded_urls keyword, polluting application traffic metrics; apply exclusions through a supported mechanism for each instrumentor.

@DanielVisca DanielVisca Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Fixed for httpx in 2ec7b27. The exclusion is also set in OTEL_PYTHON_HTTPX_EXCLUDED_URLS while the instrumentor starts (it reads the variable then), and the environment is restored after that. Test: test_does_not_record_httpx_requests_to_posthog. The async client of this SDK uses httpx, so all SDK uploads are excluded now.

Limit: the aiohttp-client instrumentor reads the variable again for each new ClientSession, so a temporary value does not reach it, and a permanent value would change the process environment. The SDK does not send to PostHog with aiohttp, so only app code that calls the PostHog host with aiohttp is still counted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Correction to my reply above: the httpx exclusion needs opentelemetry-instrumentation-httpx 0.60b0 or newer. Older versions have no URL exclusion. 7621c33 raises the minimum of opentelemetry-instrumentation in posthog[metrics] to 0.60b0; opentelemetry-bootstrap installs instrumentors at that same version. I ran the autocapture tests with exactly these minimum versions (Python 3.10, SDK 1.39.0, instrumentation 0.60b0): all pass.

Comment thread posthog/metrics_autocapture.py Outdated
Comment on lines +356 to +358
for instrumentor in self._instrumentors:
try:
instrumentor.uninstrument()

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 · Remove instrumentation from existing Flask apps during shutdown

For an existing Flask app, FlaskInstrumentor.uninstrument() restores the Flask class but leaves the app's middleware and hooks bound to the old providers. After shutdown, a replacement client receives no server HTTP metrics, and instrument_app() refuses the still-instrumented app; remove instrumentation from owned Flask app instances during cleanup.

@DanielVisca DanielVisca Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Fixed in 2ec7b27. Autocapture records the Flask apps that are created while it runs. shutdown() uninstruments them, and the next autocapture instruments them again. FlaskInstrumentor.uninstrument_app() needs _teardown_request, which the patched Flask class does not set, so autocapture stores it when the app is created. Test: test_measures_an_existing_flask_app_again_after_a_restart (the second client counts the request once). For FastAPI, the instrumentor already uninstruments its own apps; a later autocapture does not instrument an existing FastAPI app again.

Comment thread pyproject.toml Outdated
Comment on lines +58 to +59
"opentelemetry-instrumentation>=0.41b0",
"opentelemetry-instrumentation-system-metrics>=0.41b0",

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 · Require instrumentation versions that support the runtime metric names

With instrumentation pinned to the permitted 0.41b0 minimum, runtime autocapture starts successfully but emits no metrics. That version recognizes process.runtime.* configuration keys, whereas _RUNTIME_CONFIG supplies newer process.* and cpython.gc.* names; raise the dependency minimum to a compatible version or translate the configuration.

@DanielVisca DanielVisca Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Fixed in 2ec7b27. The metrics extra now needs opentelemetry-instrumentation and opentelemetry-instrumentation-system-metrics >=0.58b0 (the first release with the process.* and cpython.gc.* names), and SDK and exporter >=1.37.0, which 0.58b0 needs. I ran it end to end on Python 3.10 with exactly these versions: runtime, GC and HTTP metrics arrived.

Comment thread posthog/client.py Outdated
Comment on lines +1115 to +1117
self._metrics_autocapture = self._start_metrics_autocapture(
metrics or {}, autocapture if isinstance(autocapture, dict) else {}
)

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 · Flush autocaptured metrics through the public client lifecycle

When a serverless handler calls Client.flush() before its runtime freezes, the new autocapture provider keeps its measurements pending: that method drains event lanes and traces but never calls MetricsAutocapture.force_flush(). Requests completing before the export timer fires lose their metrics; include autocapture in the public flush within its timeout budget.

@DanielVisca DanielVisca Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Fixed in 2ec7b27. Client.flush() now exports the autocaptured window at the same time as the event lanes, within the same timeout. Test: test_flush_exports_autocaptured_metrics (with a one-hour export interval). The client's exit hook also exports the last window, within its exit budget.

Comment on lines +594 to +596
except Exception:
log.exception("Metrics autocapture failed to start")
return 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.

P2 · Clean up allocated resources when startup fails

An exception after reader creation, such as an invalid OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT during tracer construction, returns None without shutting down the reader or provider. Each startup attempt leaves another daemon export thread and retained exporter alive despite _active remaining unset. Roll back all resources allocated before the failure.

@DanielVisca DanielVisca Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Fixed in 2ec7b27. A failed start now uninstruments what it instrumented, and shuts down the tracer provider and the meter provider (or the reader). Test: test_cleans_up_when_startup_fails checks that no export thread stays and that requests is not instrumented.

Comment thread posthog/metrics_autocapture.py Outdated
Comment on lines +579 to +581
if entry_point.name in _HTTP_CLIENT_INSTRUMENTORS:
# PostHog's own uploads, including this exporter's, are not app traffic.
kwargs["excluded_urls"] = excluded_urls

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 · Preserve existing OpenTelemetry HTTP URL exclusions

When OTEL_PYTHON_REQUESTS_EXCLUDED_URLS or OTEL_PYTHON_EXCLUDED_URLS excludes sensitive destinations, passing excluded_urls replaces those exclusions in the requests instrumentor. Autocapture consequently records and exports traffic the application explicitly excluded. Merge the PostHog-host exclusion with the applicable global and library exclusions.

@DanielVisca DanielVisca Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Fixed in 2ec7b27. The excluded URLs are now the PostHog host plus the value of OTEL_PYTHON_<LIBRARY>_EXCLUDED_URLS (or OTEL_PYTHON_EXCLUDED_URLS), with the same precedence as OpenTelemetry. The host pattern also matches any case and the default port, and escapes commas. Tests: test_keeps_the_apps_excluded_urls, TestPostHogUrlExclusion.

… metrics

`metrics={"autocapture": True}` starts the installed OpenTelemetry instrumentors with private meter and tracer providers and exports to `/i/v1/metrics`. The packages come from the new `posthog[metrics]` extra.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: ef9b57c2-47ff-47e6-a509-8873ffe1fab4
Generated-By: PostHog Desktop
Task-Id: c0218209-575c-46f0-a201-d0c831e38096
- Honour send=False in the autocapture export gate.
- Strip trailing slashes from the host before building the metrics endpoint.
- Set OTEL_SEMCONV_STABILITY_OPT_IN only while the instrumentors load, then
  restore the process environment.
- Limit each synchronous instrument to 2000 attribute sets; send the rest to
  an otel.metric.overflow series, so user-supplied hosts cannot grow the SDK cache.
- After fork, replace the module lock and point the runtime instrumentor at
  the child process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: 10838116-90e2-4d23-af38-8099d7887d07
…through shutdown

- Skip an instrumentor whose singleton the app already instrumented before creating it. Creating it ran __init__ again and reset the app's system metrics config.
- Uninstrument while the module lock is held, so a client that starts during shutdown cannot skip libraries that are about to be uninstrumented. The last export still runs outside the lock.
- Correct the module docstring: an app cannot instrument a library again with its own OpenTelemetry setup while autocapture instruments it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: a129c1a0-57e3-4370-a4df-6542e710ab75
@DanielVisca
DanielVisca force-pushed the posthog/otel-metrics-autocapture branch from 7ab06e5 to c5a7cd4 Compare October 27, 2026 18:39
…read the opt-in first

OpenTelemetry reads OTEL_SEMCONV_STABILITY_OPT_IN once per process. If any instrumentor ran before the client started, autocapture sent http.client.duration (ms) and not http.client.request.duration (s). When the app has not set an opt-in, autocapture now sets the HTTP mode to stable after initialisation, so the names do not depend on import order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: a129c1a0-57e3-4370-a4df-6542e710ab75
Comment thread posthog/_metrics_autocapture.py
- Do not send the app's OTEL_EXPORTER_OTLP_* settings (other vendors' headers, endpoint, TLS files) to PostHog: the private exporter is built without them.
- Give HTTP instrumentors a no-op tracer, so app requests no longer carry an unsampled traceparent that makes downstream services drop their traces.
- Record nothing while the client is disabled, and apply metrics.before_send to autocaptured measurements and runtime observations (value and attributes, or drop).
- Exclude the PostHog host in any case and with or without its default port, escape commas, keep OTEL_PYTHON_*_EXCLUDED_URLS, and apply the exclusion to httpx.
- Reset the reader's export lock in a forked child, export autocaptured metrics on Client.flush() and within the client's exit budget (no 10 s exit wait), roll back on a failed start, and uninstrument and reinstrument Flask apps across a restart.
- Start autocapture on a later module-level setup(), once per client.
- Validate flush_interval like client.metrics, use the same resource as client.metrics, keep the first word of db.operation.name, and raise the minimum OpenTelemetry versions to the first ones with the runtime metric names.
- Rename the module to posthog/_metrics_autocapture.py to keep internals out of the public API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: a129c1a0-57e3-4370-a4df-6542e710ab75

@posthog posthog Bot 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.

PostHog Review · Standard · reviewed 2ec7b27: 2 Must fix, 5 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

if instrumentor.is_instrumented_by_opentelemetry:
# The app instrumented this library itself.
continue
instrumentor.instrument(**kwargs)

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 · Avoid configuring Django settings during instrumentation discovery

With Django instrumentation installed and no DJANGO_SETTINGS_MODULE, this call makes DjangoInstrumentor configure default settings. An application that subsequently calls settings.configure(...) raises RuntimeError: Settings already configured and cannot start. Defer Django instrumentation until the application supplies its settings.

@DanielVisca DanielVisca Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Correction: confirmed, and fixed in ee2a9d9. My earlier "not reproduced" was wrong. With no DJANGO_SETTINGS_MODULE, the Django instrumentor calls settings.configure() with empty settings (it catches ImproperlyConfigured and configures defaults), and a later settings.configure(...) by the app fails with Settings already configured. This happens on 2ec7b27 and 7621c33 too. Autocapture now starts the Django instrumentor only when the settings are already configured, as in AppConfig.ready(). Tests (in a subprocess each, because Django settings are global): test_does_not_configure_django_settings (failed before the fix with Settings already configured) and test_instruments_django_once_its_settings_are_configured.

Comment on lines +931 to +935
kwargs: Dict[str, Any] = {
"tracer_provider": tracer_provider
if entry_point.name in _DB_INSTRUMENTORS
else no_op_tracer_provider,
"meter_provider": bounded_meter_provider,

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 · Suppress baggage propagation in metrics-only HTTP instrumentation

When an application has active OpenTelemetry baggage, HTTP autocapture makes instrumented requests calls inject that baggage into outgoing headers, including requests to third-party hosts. NoOpTracerProvider prevents spans but does not disable propagate.inject(), exposing context data through a metrics-only feature. Suppress propagation in autocapture-owned HTTP instrumentation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Confirmed and fixed in 7621c33. While autocapture runs, the instrumentors it starts call a no-op in place of inject (they call it through their module global), and shutdown() restores it. So no baggage and no trace context go to other services. That includes a trace that a server instrumentor extracted from an incoming request, which 2ec7b27 still forwarded. Test: test_does_not_propagate_context_to_other_services (requests and httpx, with baggage and a remote parent span; checks no traceparent, tracestate or baggage, and that inject is restored after shutdown).

Comment on lines +874 to +875
meter_provider = sdk_metrics.MeterProvider(
resource=resource, metric_readers=[reader], shutdown_on_exit=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.

P2 · Drain autocaptured metrics at exit for synchronous clients

With sync_mode=True, Client.__init__ skips its _atexit registration, while this provider disables its own exit hook. A process that exits before the export interval drops its pending autocaptured measurements. An executed SQLite query exports on default-client exit but sends nothing in synchronous mode; register a bounded metrics exit drain for these clients.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Confirmed and fixed in 7621c33. With sync_mode=True and send=True, the client now registers an exit hook that exports the autocaptured window within the client's exit budget. Test: test_sync_mode_exports_the_last_window_at_exit (a subprocess runs one SQLite query and exits; the metrics arrive).

Comment on lines +661 to +662
if getattr(self._reader, "_export_lock", None) is not None:
self._reader._export_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.

P2 · Reinitialize metric collection locks after fork

Forking while runtime collection holds OpenTelemetry 1.40's _measurement_consumer._lock leaves that lock permanently held in the child. _reinit_after_fork() resets only _export_lock, so subsequent collections and force_flush() hang despite their timeouts. Recreate the provider state or reinitialize all inherited collection and aggregation locks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Confirmed and fixed in 7621c33. In the child, autocapture now replaces the locks of the metrics SDK that a parent thread can hold while it collects or records: the measurement consumer, the reader storages, the view instrument matches, the aggregations and their exemplar reservoirs (and the meters' registration locks). The SDK itself replaces only the provider's locks. A held aggregation lock was the worst case, because the child's next measurement on that series blocked the app thread. Test: test_records_in_a_child_forked_during_a_collection holds all these locks in other threads at fork; the child records and flushes. Without the fix the child hangs.

Comment thread posthog/client.py
Comment on lines +2844 to +2845
autocapture = self._metrics_autocapture
return autocapture.start_flush() if autocapture is not None 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.

P2 · Coalesce concurrent autocapture flush workers

When exports stall and callers repeatedly use short flush timeouts, each _start_metrics_autocapture_flush() starts another daemon thread. Timing out only stops joining; workers remain blocked behind OpenTelemetry's export lock and accumulate without a bound, exhausting threads and memory under load. Reuse one in-flight flush worker.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Confirmed and fixed in 7621c33. One flush thread runs at a time. A call made while it exports gets the same thread to wait on, and asks it for one more export, so measurements recorded before that call are also sent. Test: test_coalesces_concurrent_flushes (5 calls during a blocked export start 1 thread and give 2 exports). Without the fix: 5 threads.

Comment thread posthog/client.py Outdated
export_interval_seconds=_validated_flush_interval(
metrics_config.get("flush_interval", _DEFAULT_FLUSH_INTERVAL_SECONDS)
),
before_send=_validated_before_send(metrics_config.get("before_send")),

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 · Mark metrics hooks as lifecycle callbacks before invoking them

When a runtime before_send hook calls client.flush(timeout_seconds=None), OpenTelemetry holds its collection lock while the hook waits for a new flush worker that needs that same lock. Forwarding the hook without lifecycle callback context bypasses the client's deferral guard and deadlocks collection. Wrap hook execution in that context.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Confirmed and fixed in 7621c33. The client now runs metrics.before_send in its lifecycle callback context, so a flush() or shutdown() that the hook calls is deferred and does not wait for the collection that is running the hook. The flush thread also never waits for itself. Test: test_before_send_can_call_flush (a subprocess, so a deadlock cannot hang the suite); without the fix it reports a deadlock.

Comment thread posthog/_metrics_autocapture.py Outdated
if kind == trace_api.SpanKind.CLIENT
else sampling.Decision.DROP
)
return sampling.SamplingResult(decision)

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 · Preserve initial span attributes in the sampling result

With asyncpg instrumentation installed, _do_execute() supplies db.system and query attributes when starting the span. OpenTelemetry initializes recorded spans from SamplingResult.attributes, but this sampler returns none, so DbSpanMetricsProcessor drops every asyncpg span for missing db.system. Preserve the input attributes in the sampling result.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Confirmed and fixed in 7621c33. For client spans, the sampler now returns the start attributes in the SamplingResult, so the recorded span keeps them. Test: test_keeps_the_attributes_a_database_span_starts_with (a span with the asyncpg scope that starts with db.system gives a db.client.operation.duration point).

Comment thread posthog/_metrics_autocapture.py Outdated

DanielVisca commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

@marandaneto Yes. All review comments are now addressed, in c5a7cd4 and 2ec7b27. Each fix has a test that failed before it, and I replied on each thread. The branch is also rebased onto main.

The second commit also fixes these points from a further review pass, which have no thread:

  • Outgoing app requests no longer carry a traceparent header. Before, the private tracer added an unsampled traceparent, and downstream services then dropped their traces. HTTP instrumentors now get a no-op tracer, because HTTP metrics do not need spans.
  • When the client is not shut down, exit no longer waits about 10 s for an unreachable PostHog host. The last window is sent within the client's exit budget.
  • An invalid flush_interval falls back to 10 s, as in client.metrics. Before, it turned autocapture off, or exported every second for True.
  • Autocapture uses the same resource as client.metrics, so OTEL_SERVICE_NAME no longer gives the two metric streams different service names.
  • The module is private (posthog/_metrics_autocapture.py), so it is no longer in the public API snapshot.
  • Tests: an autouse fixture stops leftover autocapture between tests, the fork tests hold the locks in another thread, and there are new tests for the client option mapping.
  • Docs: the changeset and the docstrings now say that the feature is alpha, that the client must be created before the web app, and how before_send applies.

There is one remaining limit, explained in the thread: the aiohttp-client instrumentor reads its URL exclusions again for each session. The SDK does not use aiohttp to send to PostHog.

DanielVisca commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Update: all 8 findings are now resolved, in 7621c33 and ee2a9d9. Each fix has a test that failed before it, and each thread has a reply. Two corrections to the table below: the Django finding is real (my first check was wrong; it is fixed in ee2a9d9), and the three unchecked findings (fork collection locks, flush threads, before_send calling flush()) were all confirmed and fixed in 7621c33. 7621c33 also raises the minimum opentelemetry-instrumentation to 0.60b0, the first version in which the httpx instrumentor excludes URLs; the autocapture tests pass with exactly the minimum versions (Python 3.10, SDK 1.39.0, instrumentation 0.60b0).


Earlier status, kept for the record:

The 8 comments from posthog[bot] and veria-ai[bot] on 2ec7b27 came in while the replies above were being posted. None of them is addressed yet. I checked each one on 2ec7b27:

Finding Status
veria-ai: shutdown removes app WSGI middleware (Flask) Confirmed. This is a regression from 2ec7b27. An auth middleware added after Flask(...) gave 401 before shutdown() and 200 after it, because uninstrument_app() restores the wsgi_app saved at creation.
P1: baggage is propagated to third-party hosts Confirmed. With active baggage user.email=a@b.c, an instrumented requests call sent baggage: user.email=a%40b.c. The no-op tracer stops traceparent only.
P2: sampler drops span start attributes (asyncpg) Confirmed. A span started with attributes={"db.system": "postgresql"} has no attributes, so these DB spans give no metric.
P2: sync_mode=True drops the last window at exit Confirmed. A SQLite query exported at exit with the default client, and nothing with sync_mode=True.
P1: Django instrumentor configures default settings Not reproduced. With no DJANGO_SETTINGS_MODULE, settings.configure(...) still worked after autocapture started.
P2: collection locks after fork Not checked yet.
P2: one flush thread per flush() call can pile up Not checked yet. Plausible from the code: each call starts a new thread.
P2: before_send that calls flush() can deadlock Not checked yet.

Because of the Flask regression, this PR should not merge until it is fixed. Fixes for the confirmed findings will follow, each with a test that fails first.

…on, and fix fork and flush issues

- Flask: shutdown uninstruments an app only when its WSGI app is still the one the instrumentation set. uninstrument_app() would otherwise remove middleware the app added later, such as authentication. Instrumentation left in place records nothing after shutdown.
- Do not propagate context: the instrumentors that autocapture starts no longer inject trace context or baggage into outgoing requests, including a trace extracted from an incoming request.
- Keep the attributes that a database span starts with (asyncpg passes db.system then).
- Export the last window at exit in sync mode.
- Replace the metrics SDK collection and aggregation locks in a forked child, where a parent thread could hold them.
- Use one flush thread at a time, with one follow-up export for calls made during a flush.
- Run metrics.before_send as an SDK callback, so a flush() it calls is deferred and cannot deadlock the collection.
- Raise the minimum OpenTelemetry instrumentation version to 0.60b0, the first in which the httpx instrumentor excludes URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: a129c1a0-57e3-4370-a4df-6542e710ab75

@posthog posthog Bot 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.

PostHog Review · Standard · reviewed 7621c33: 2 Must fix, 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

This is review round 3 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

Comment thread posthog/client.py Outdated
Comment on lines +1175 to +1178
callback_context = client._lifecycle_callback_context
token = callback_context.set(True)
try:
return hook(sample)

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 · Prevent runtime hooks from recursively scheduling flushes

When a runtime before_send hook calls client.flush(), every deferred flush collects runtime metrics and invokes the hook again. run_as_callback keeps scheduling follow-up flushes indefinitely, causing continuous OTLP requests and CPU use even with flush_interval=3600; suppress recursive flush requests during a hook-triggered drain.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Confirmed and fixed in 7a10866. Before the fix, a runtime hook that calls client.flush() caused 1333 exports in 2 seconds with flush_interval=3600. A flush() called from a metrics before_send hook still flushes events and spans (deferred), but does not start another autocapture export: the hook already runs inside an export, which sends the measurement it filters. The deferred flush keeps this marker, and coalesced follow-ups merge it. Test: test_a_flush_from_before_send_does_not_repeat (exactly 1 export).

Comment on lines +231 to +233
key = frozenset(attributes.items()) if attributes else frozenset()
except (AttributeError, TypeError):
return attributes

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 · Normalize hook attributes before passing them to OpenTelemetry

When metrics.before_send adds a list-valued attribute such as deployment.tags=["blue", "canary"], _bounded() catches the hashing error but forwards the unchanged attributes. On supported OpenTelemetry 1.39.0, histogram recording then raises TypeError into requests.get() after its HTTP request succeeds. Normalize sequences to tuples and contain recording errors.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Confirmed and fixed in 7a10866. On SDK 1.39.0, recording a list attribute raised TypeError: unhashable type: 'list' (1.45 converts lists itself). Attributes are now cleaned before and after before_send: sequences of valid values become tuples, and other values (such as dicts) are dropped. This also applies to runtime observations. Recording errors are now caught, so they never reach the app's call. Tests: test_normalizes_attributes_from_before_send, test_normalizes_runtime_attributes_from_before_send, test_never_raises_into_the_instrumented_call. With SDK 1.39.0 the first test fails without the fix.

Comment thread posthog/_metrics_autocapture.py Outdated
Comment on lines +715 to +719
flusher = self._flush_thread
if flusher is not None:
self._flush_again = True
# From the export itself, waiting would never end.
return None if flusher is threading.current_thread() else flusher.join

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 · Complete flush waiters independently of subsequent flush requests

When concurrent callers request flushes faster than exports finish, _flush_again keeps the shared worker alive. Returning flusher.join makes earlier callers wait for all subsequent exports, even after their measurements were sent. Sustained traffic makes finite calls exhaust their timeout and flush(None) wait indefinitely. Track completion separately for each requested flush generation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Confirmed and fixed in 7a10866. Flush requests are now numbered. An export covers the requests made before it starts, and each caller waits only until that export finishes, not for later ones. There is still one worker thread, which runs one more export for requests made during an export. Test: test_a_flush_waits_only_for_the_export_after_it (the first caller returns while the second export is still blocked). test_coalesces_concurrent_flushes still checks 1 thread and 2 exports.

…ings

Without configured settings, DjangoInstrumentor calls settings.configure() with empty settings, so an app that configures its settings in code after creating the client fails with "Settings already configured". Autocapture now starts the Django instrumentor only when the settings are configured, as in AppConfig.ready().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: a129c1a0-57e3-4370-a4df-6542e710ab75

@posthog posthog Bot 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.

PostHog Review · Standard · reviewed ee2a9d9: 1 Must fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

This is review round 4 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

Comment thread posthog/_metrics_autocapture.py Outdated
Comment on lines +262 to +264
return getattr(self._instrument, method)(
amount, self._bounded(attributes), *args, **kwargs
)

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 · Strip sensitive HTTP URL attributes before recording

With Tornado instrumentation 0.60b0 installed, HTTP autocapture forwards raw paths as http.target and full client URLs as http.url. A request to /reset/private-token?email=person@example.com puts the token and email into exported metric attributes; _bounded() only limits series count. Remove raw URL attributes or replace them with safe route templates before recording.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Robot review: the PostHog Slack app (an AI agent) wrote and posted this from @DanielVisca's account. Fully automated AI review; this comment was not individually reviewed by a human before posting.

Confirmed and fixed in 7a10866. Autocapture now never records raw URLs and paths (url.full, url.path, url.query, http.url, http.target), client addresses (client.address, http.client_ip, net.peer.ip, net.sock.peer.addr), user agents, or captured http.request.header.* / http.response.header.* values. Routes such as /users/{id} stay. The filter runs on every measurement, before before_send. Test: test_drops_raw_url_and_client_attributes. I also checked it end to end with a real Tornado server and client (instrumentation 0.66b1): on ee2a9d9 the client metric exported url.full with the token and the email; on 7a10866 nothing leaks, and the server metric keeps http.route=/reset/{token}.

… stop flush loops from hooks

- Never record raw URLs and paths (url.full, http.url, http.target, ...), client addresses, user agents or captured headers. Instrumentors such as Tornado put them on metrics, and they can hold tokens and emails. Routes stay.
- Keep only valid OpenTelemetry attribute values, with sequences as tuples, before and after metrics.before_send. SDK 1.39 raised TypeError for a list value inside the app's request. Recording errors no longer reach the instrumented call.
- A flush() called from a metrics before_send hook still flushes events and spans, but does not start another autocapture export, which ran the hook again without end.
- Each flush request waits only for the first export that starts after it, not for later exports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: a129c1a0-57e3-4370-a4df-6542e710ab75

@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.

AI-assisted review.

The follow-up resolves most earlier sampler, filtering, fork and lifecycle reproductions. Three verified cases still need correction or an explicit acceptance decision: concurrent application exporter configuration, cardinality admission across aiohttp sessions, and retained-session baggage propagation after shutdown. Details and regression controls are inline.

return super().export(
metrics_data, timeout_millis=timeout_millis, **kwargs
)

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.

R1 — [P1] Keep exporter configuration isolation instance-local

Location: posthog/_metrics_autocapture.py:1070–1096, RIGHT 1070.
Relationship: Introduced by the credential-isolation repair; remains from 2ec7b27.

Temporarily deleting application OTEL_EXPORTER_OTLP_* settings can permanently misconfigure an unrelated exporter constructed concurrently. Restoring environment afterward does not repair configuration already captured.

The executed public combination was PostHog autocapture initialization alongside a real application OTLPLogExporter. A timing barrier paused the real metric-exporter constructor; the harness did not change environment during the overlap.

Control Application result
Pre-isolation feature control, e543161 Configured destination/authentication retained
2ec7b27 and current head Default localhost:4318/v1/logs; missing authentication
Actual owned collector, pre-isolation control Authenticated request, 200
Same collector, current head Unauthenticated request, 401, actual export failure

This requires concurrent startup, not ordinary sequential setup. Logs-only applications pass the meter/tracer-provider checks. The test disables instrumentation areas to isolate construction; this block runs before area selection for normal configurations too.

Official exporter sources confirm constructor-time environment resolution on both supported SDK versions. Private exporter initialization must not corrupt application configuration.

Consider configuring the private exporter/transport per instance without deleting process-global values. A PostHog-only lock cannot coordinate unrelated constructors. Preserve serial credential-canary coverage: reverting alone restores the earlier leak.

Executed regression: regression-environment-{race,wire}.py {pre-env,prior,head}. Current assertions fail for the configuration/401 reasons above. Only synthetic credentials and owned endpoints were used; no production incident or external disclosure demonstrated.

self._stopped = stopped

def _bounded(self, factory: str, args, kwargs) -> _BoundedInstrument:
name, unit = _instrument_name_and_unit(args, kwargs, unit_index=1)

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.

R2 — [P1] Share admission state for wrappers of one native instrument

Location: posthog/_metrics_autocapture.py:350–364, RIGHT 354; related 460–468.
Relationship: Introduced by the full PR; also present in 2ec7b27.

Every factory call creates a fresh bounded wrapper and _seen set, but OTel caches the underlying meter/histogram. Multiple wrappers independently admit labels into the same native instrument.

Official aiohttp instrumentation creates instruments per session. This ordinary caller consequently resets admission:

# One PostHog client, HTTP autocapture enabled, no measurement hook.
for url in urls:
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            await response.read()

With the default 2000 limit and identical 2005-destination sequence:

Caller Successful requests Series Overflow
One reused session 2005 2001 1
New session per request 2005 2005 0

These used real transport, a supported resolver mapping only to an owned server, and actual OTLP output. No hook or private SDK mutation. A Flask control independently reproduced the same mechanism across safe route labels.

SDK 1.45.1 retains admitted aggregations after sessions close. The resource bound therefore depends on caller object reuse rather than the native instrument’s lifetime. The list-normalization repair fixes a different bypass.

Consider sharing admission state—or caching wrappers—by native-instrument identity across meter proxies. Caching only inside a newly created _BoundedMeter is insufficient.

Executed regression: regression-aiohttp.py {one,many} {prior,head} and regression-flask-shared-cap.py {one,many}. One-session cases pass; many-session cases fail the 2001-series assertion. Expected: both stay within 2000 ordinary series plus one overflow. No OOM was induced.

log.debug(
"Metrics autocapture failed to uninstrument Flask apps", exc_info=True
)
_restore_propagation()

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.

R3 — [P1] Detach retained aiohttp hooks before restoring propagation

Location: posthog/_metrics_autocapture.py:643–649; cleanup call RIGHT 894.
Relationship: Full-PR lifecycle residual; active-period propagation repair is incomplete at shutdown.

Native aiohttp uninstrumentation restores the constructor, but existing sessions retain trace callbacks. Restoring module inject makes those SDK-installed callbacks transmit baggage after shutdown.

# Inside an async function; URLs refer to owned fixtures.
client = Posthog("phc_test_key", host=collector_url, sync_mode=True, metrics={
    "autocapture": {"http": True, "db": False, "runtime": False},
    "flush_interval": 3600,
})
token = context.attach(baggage.set_baggage("tenant", "synthetic-canary"))
try:
    async with aiohttp.ClientSession() as session:
        async with session.get(destination_url) as response:
            await response.read()  # No baggage transmitted.
        client.shutdown()
        async with session.get(destination_url) as response:
            await response.read()  # Baggage now transmitted.
finally:
    context.detach(token)

Requests are awaited sequentially; no shutdown race or private context mutation.

Revision Plain session / owned active / owned after shutdown / plain afterward
Merge base None / None / None / None
2ec7b27 None / canary / canary / None
Current None / None / canary / None

Every request returned 200. Caller baggage remained attached; active owned-session measurement was verified. Stopping metric recording does not disable these propagation callbacks.

Official contrib 0.66b1 confirms that global uninstrumentation only unwraps the constructor and provides a separate uninstrument_session() API.

Consider detaching SDK-owned trace configurations or otherwise making retained callbacks inert before restoring injection. Preserve caller context/application instrumentation; do not replace the global propagator or close application-owned sessions.

Executed regression: regression-aiohttp.py retained {base,prior,head}. Base passes; current fails the after-shutdown assertion. Only a synthetic canary reached an owned endpoint. No post-shutdown traceparent result or external exfiltration incident is claimed.

pypi/posthog: minor
---

Add `metrics={"autocapture": True}` (alpha): HTTP, database and runtime metrics from the official OpenTelemetry instrumentations, with no instrumentation code. Install `posthog[metrics]` and run `opentelemetry-bootstrap -a install` to add the instrumentors for your libraries. Create the client before your web app (for example before `Flask(...)`, or in Django's `AppConfig.ready()`): apps created earlier are not measured. Autocapture does not start when the app already set up OpenTelemetry; export those metrics to PostHog instead. Raw URLs and paths, client addresses, user agents and captured headers are never recorded. `metrics.before_send` also applies to autocaptured measurements: it can change their value and attributes, or drop them.

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.

O1 — [P2] Clarify Flask class-import ordering

Location: .sampo/changesets/metrics-autocapture.md:5, RIGHT.
Disposition: OPTIONAL documentation correction.

The documented “client before Flask(...)” order still fails with an existing alias:

from flask import Flask
client = Posthog(..., metrics={"autocapture": True})
app = Flask(__name__)

Both wrappers replace flask.Flask, not this alias. Current execution records zero server measurements versus one using module lookup.

Consider showing initialization before class import, or:

import flask
client = Posthog(..., metrics={"autocapture": True})
app = flask.Flask(__name__)

Verified with regression-flask-alias.py {alias,module}. No additional instrumentation layer is requested.

Comment thread posthog/client.py
)
# Sync mode has no exit hook for events; the window needs its own.
if self._metrics_autocapture is not None and self.sync_mode and self.send:
atexit.register(self._atexit_metrics_autocapture)

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.

O2 — [P2] Unregister the synchronous metrics exit hook

Location: posthog/client.py:1151–1153, RIGHT 1153.
Disposition: OPTIONAL; introduced by the sync-exit repair.

The bound callback retains a closed client until process exit. Shutdown clears the autocapture handle but never unregisters this callback; tracing unregisters its equivalent.

Executed three-cycle create/shutdown/weakref/GC controls:

  • 2ec7b27, plain/autocapture: 0/3 retained.
  • Current plain synchronous clients: 0/3.
  • Current synchronous autocapture clients: 3/3.

Consider unregistering _atexit_metrics_autocapture during cleanup while retaining implicit-exit delivery coverage.

Regression: regression-sync-retention.py {prior,head} {plain,auto}. This demonstrates retention, not exhaustion.


flask.Flask = _TrackedFlask
for app in list(_flask_apps):
if not getattr(app, "_is_instrumented_by_opentelemetry", 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.

O3 — [P2] Roll back failed instrumentation of a served Flask app

Location: posthog/_metrics_autocapture.py:581–585, RIGHT 583.
Disposition: OPTIONAL; present within both feature revisions.

Native instrument_app() replaces WSGI before registering before_request(). Flask rejects registration after the first request. Autocapture catches the exception but leaves partial instrumentation.

Executed replacement-client cycles show:

  • Initial 200/503 responses have correct metric status.
  • Restarted app returns 503, but its metric has no status attribute.
  • Completed-instrumentation flag remains false.
  • Wrapper depth after shutdown grows 1 → 2 → 3.
  • DEBUG output captures the native setup assertion three times.

The count-only restart test passes despite this incomplete state.

Consider rolling back failed per-app changes and safely skipping/qualifying unsupported re-instrumentation, or choosing an explicit safe restart strategy. Preserve the authentication fix and framework lifecycle guard. Check status and cleanup, not count alone.

Regression: regression-flask-partial-restart.py {prior,head}. Optional because public guidance does not promise broad served-app restart support. No response failure, total metric loss, authentication bypass or OOM claimed.

@dustinbyrne
dustinbyrne requested a review from a team October 28, 2026 19:30

This branch has not been deployed

No deployments
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.

3 participants