Skip to content

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

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

DanielVisca wants to merge 6 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.37+, instrumentation 0.58b0+, the first release with the runtime metric names). 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, so app requests carry no trace context.
  • 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.
  • 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(...)). 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 (50 tests; each review fix has a test that failed before it). ruff, mypy (baseline) and python -W error -c "import posthog" clean. Full suite: 5668 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.37.0 and instrumentation 0.58b0, an end-to-end run sent HTTP, process.* and cpython.gc.* metrics, applied before_send, sent no vendor OTLP headers and added no traceparent. urllib, urllib3, aiohttp-client and FastAPI were checked the same way (status code and http.route kept, no traceparent).

📝 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 and 2ec7b27), 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-27T20:16:05.026169+00:00
Duration: 259291ms

✅ 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 ✅ 516ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 511ms
Required Headers.Has Authorization Bearer Header ✅ 508ms
Required Headers.Has Content Type Json ✅ 510ms
Required Headers.Has Posthog Sdk Info Format ✅ 509ms
Required Headers.Has Posthog Attempt Header ✅ 510ms
Required Headers.Has Posthog Request Id ✅ 508ms
Required Headers.Has Posthog Request Timestamp ✅ 510ms
Required Headers.Has User Agent ✅ 508ms
Body Format.Body Has Created At And Batch ✅ 508ms
Body Format.No Api Key In Body ✅ 509ms
Body Format.No Sent At In Body ✅ 508ms
Event Format.Event Has Required Root Fields ✅ 509ms
Event Format.Event Uuid Is Valid ✅ 509ms
Event Format.Event Timestamp Is Rfc3339 ✅ 510ms
Event Format.Non Utc Event Timestamp Is Converted To Utc ✅ 518ms
Event Format.Distinct Id Is String ✅ 508ms
Event Format.Distinct Id At Root Not Properties ✅ 510ms
Event Format.Custom Properties Preserved ✅ 509ms
Event Format.Set Properties Preserved ✅ 509ms
Event Format.Set Once Properties Preserved ✅ 509ms
Event Format.Groups Properties Preserved ✅ 509ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 509ms
Event Format.Event Has Required Root Fields Batch ✅ 513ms
Event Format.Event Uuid Is Valid Batch ✅ 512ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 513ms
Event Format.Distinct Id Is String Batch ✅ 512ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 510ms
Event Format.Custom Properties Preserved Batch ✅ 511ms
Event Format.Set Properties Preserved Batch ✅ 513ms
Event Format.Set Once Properties Preserved Batch ✅ 514ms
Event Format.Groups Properties Preserved Batch ✅ 511ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 512ms
Batch Behavior.Multiple Events In Single Batch ✅ 513ms
Batch Behavior.Batch Envelope Smoke ✅ 512ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 505ms
Batch Behavior.Flush At Triggers Batch ✅ 1008ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 510ms
Deduplication.Generates Unique Uuids ✅ 515ms
Deduplication.Different Events Same Content Different Uuids ✅ 510ms
Deduplication.Preserves Uuid On Retry ✅ 6516ms
Deduplication.Preserves Timestamp On Retry ✅ 6518ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 6519ms
Deduplication.No Duplicate Events In Batch ✅ 514ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 508ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 13520ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 6518ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 3015ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 6513ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 508ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 511ms
Response Format Validation.Success No Retry After When All Ok ✅ 509ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 1512ms
Response Format Validation.Success No Retry After When Drop Only ✅ 509ms
Response Format Validation.Response Echoes Request Id ✅ 508ms
Retry Behavior.Retries On 408 ✅ 6512ms
Retry Behavior.Retries On 500 ✅ 6512ms
Retry Behavior.Retries On 503 ✅ 8521ms
Retry Behavior.Retries On 504 ✅ 6512ms
Retry Behavior.Retryable Errors Have Retry After ✅ 3514ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 11512ms
Retry Behavior.Does Not Retry On 400 ✅ 2511ms
Retry Behavior.Does Not Retry On 401 ✅ 2511ms
Retry Behavior.Does Not Retry On 402 ✅ 2510ms
Retry Behavior.Does Not Retry On 413 ✅ 2511ms
Retry Behavior.Does Not Retry On 415 ✅ 2509ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2510ms
Retry Behavior.Implements Backoff ✅ 22532ms
Retry Behavior.Max Retries Respected ✅ 22520ms
Partial Batch Handling.Handles 200 Full Success ✅ 2510ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3512ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3512ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 3510ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 6518ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 6518ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 6519ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 6517ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 6514ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 6515ms
Partial Batch Handling.Respects Retry After On Partial ✅ 8518ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3510ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 3514ms
Compression.Sends Gzip Content Encoding ✅ 508ms
Compression.No Content Encoding When Disabled ✅ 508ms
Compression.Compressed Body Is Decompressible ✅ 508ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2510ms
Event Options.Cookieless Mode Override ✅ 508ms
Event Options.Disable Skew Correction Override ✅ 508ms
Event Options.Process Person Profile Override ✅ 508ms
Event Options.Product Tour Id Override ✅ 508ms
Event Options.Unset Options Omitted ✅ 507ms
Event Options.Options Override In Batch ✅ 510ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 508ms
Geoip And Historical Migration.Historical Migration Set In Body ✅ 508ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 508ms

Capture_Ai Tests

✅ 5/5 tests passed

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

Feature_Flags Tests

✅ 17/17 tests passed

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

Feature_Flags_Local_Evaluation Tests

✅ 4/4 tests passed

View Details
Test Status Duration
Versioned Boolean Matching.Matching Version Missing ✅ 45ms
Versioned Boolean Matching.Matching Version 1 ✅ 42ms
Versioned Boolean Matching.Matching Version 2 ✅ 43ms
Versioned Boolean Matching.Version Only Reload 1 2 1 2 Missing ✅ 23ms

@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

The PR adds automatic capture of HTTP, database, and runtime metrics, including Flask instrumentation and its shutdown lifecycle.

Three issues have been addressed, with one remaining concern in Flask instrumentation cleanup. If the client is shut down while Flask continues serving, cleanup can remove authentication middleware added after app creation, allowing unauthenticated callers to reach routes it previously protected. The impact is an authentication bypass, contingent on that middleware setup and shutdown lifecycle.

Open issues (1)

Fixed/addressed: 3 · PR risk: 7/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.

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.

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.

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.

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.

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.

Comment thread posthog/client.py
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.

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.

return
for app in list(_flask_apps):
if getattr(app, "_is_instrumented_by_opentelemetry", False):
flask_instrumentation.FlaskInstrumentor.uninstrument_app(app)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium: Shutdown removes application authentication middleware

uninstrument_app() restores app.wsgi_app to the callable saved during Flask(...), discarding any WSGI authentication middleware added afterwards. (raw.githubusercontent.com) If client.shutdown() disposes of or replaces the client while Flask keeps serving, unauthenticated callers can then reach routes protected by that middleware; I reproduced a 401-to-200 transition using the locked versions and this shutdown path.

Preserve application-added wrappers when removing instrumentation, or skip per-app uninstrumentation when the WSGI stack has changed.

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.

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.

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.

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.

2 participants