Repository navigation
feat(metrics): metrics autocapture for HTTP, database and runtime metrics - #992
DanielVisca wants to merge 9 commits into
Conversation
posthog-python Compliance ReportDate: 2026-10-27T21:58:09.916612+00:00 ✅ All Tests Passed!121/121 tests passed Capture_V1 Tests✅ 95/95 tests passed View Details
Capture_Ai Tests✅ 5/5 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
Feature_Flags_Local_Evaluation Tests✅ 4/4 tests passed View Details
|
|
[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..." |
|
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. |
|
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. |
There was a problem hiding this comment.
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 |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 4 · PR risk: 0/10 |
|
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 |
marandaneto
left a comment
There was a problem hiding this comment.
Automated advisory code review.
| try: | ||
| instrumentor_class = entry_point.load() | ||
| instrumentor = ( | ||
| instrumentor_class(config=_RUNTIME_CONFIG) |
There was a problem hiding this comment.
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'.
There was a problem hiding this comment.
🤖 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
|
@DanielVisca Do you intend to address the review comments on this PR? |
There was a problem hiding this comment.
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.
| is_enabled=lambda: (client := weak_self()) is not None | ||
| and not client.disabled | ||
| and client.send, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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).
| 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"), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| 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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
There was a problem hiding this comment.
🤖 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.
| for instrumentor in self._instrumentors: | ||
| try: | ||
| instrumentor.uninstrument() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| "opentelemetry-instrumentation>=0.41b0", | ||
| "opentelemetry-instrumentation-system-metrics>=0.41b0", |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| self._metrics_autocapture = self._start_metrics_autocapture( | ||
| metrics or {}, autocapture if isinstance(autocapture, dict) else {} | ||
| ) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| except Exception: | ||
| log.exception("Metrics autocapture failed to start") | ||
| return None |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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
7ab06e5 to
c5a7cd4
Compare
…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
- 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
| if instrumentor.is_instrumented_by_opentelemetry: | ||
| # The app instrumented this library itself. | ||
| continue | ||
| instrumentor.instrument(**kwargs) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| 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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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).
| meter_provider = sdk_metrics.MeterProvider( | ||
| resource=resource, metric_readers=[reader], shutdown_on_exit=False |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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).
| if getattr(self._reader, "_export_lock", None) is not None: | ||
| self._reader._export_lock = threading.Lock() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| autocapture = self._metrics_autocapture | ||
| return autocapture.start_flush() if autocapture is not None else None |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| 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")), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| if kind == trace_api.SpanKind.CLIENT | ||
| else sampling.Decision.DROP | ||
| ) | ||
| return sampling.SamplingResult(decision) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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).
@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 The second commit also fixes these points from a further review pass, which have no thread:
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. |
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, 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:
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
There was a problem hiding this comment.
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.
| callback_context = client._lifecycle_callback_context | ||
| token = callback_context.set(True) | ||
| try: | ||
| return hook(sample) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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).
| key = frozenset(attributes.items()) if attributes else frozenset() | ||
| except (AttributeError, TypeError): | ||
| return attributes |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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.
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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
There was a problem hiding this comment.
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.
| return getattr(self._instrument, method)( | ||
| amount, self._bounded(attributes), *args, **kwargs | ||
| ) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
🤖 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
left a comment
There was a problem hiding this comment.
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 | ||
| ) | ||
|
|
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
| ) | ||
| # 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) |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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.
💡 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[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 installadds the instrumentors for the installed libraries; autocapture finds them through theopentelemetry_instrumentorentry points, asopentelemetry-instrumentdoes.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'sOTEL_PYTHON_*_EXCLUDED_URLSare 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.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).cpython.gc.*fromsystem_metrics(process-level only, no host-widesystem.*).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 orsend=False.metrics.before_sendapplies 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.telemetry.distro.name=posthog-pythonso we can measure adoption.AppConfig.ready(); Flask: beforeFlask(...)). 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-levelsetup()withmetrics={"autocapture": ...}also starts it.posthog/_metrics_autocapture.py);metrics={"autocapture": ...}is the only public surface.💚 How did you test it?
posthog/test/test_metrics_autocapture.py(65 tests; each review fix has a test that failed before it).ruff,mypy(baseline) andpython -W error -c "import posthog"clean. Full suite: 5683 passed, 0 failed.test_async_tracesintest/ai/langchain/test_callbacks.pynow checks the concurrent traces without assuming their order.hogliwith themetricsintent): a Flask + SQLAlchemy/psycopg2 + redis + requests app. In ClickHouse I sawhttp.server.request.duration(withhttp.route),http.client.request.duration,db.client.operation.duration(postgresql and redis),process.*andcpython.gc.*. No series for the PostHog host.http.routefor FastAPI) and sent notraceparentor baggage, also with active baggage. On 7a10866, a real Tornado server and client exported no raw URL, token or email (the server metric keepshttp.route).📝 Checklist
If releasing new changes
sampo addto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Connection.executeshortcut; drivers and SQLAlchemy use cursors, so the tests use cursors.Created with PostHog Desktop
🤖 Generated with Claude Code