Repository navigation
feat(metrics): metrics autocapture for HTTP, database and runtime metrics - #992
DanielVisca wants to merge 6 commits into
Conversation
posthog-python Compliance ReportDate: 2026-10-27T20:16:05.026169+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 overviewThe 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 |
|
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.
| 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.
| 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.
| 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.
| 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.
| 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.
| 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.
| 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.
| return | ||
| for app in list(_flask_apps): | ||
| if getattr(app, "_is_instrumented_by_opentelemetry", False): | ||
| flask_instrumentation.FlaskInstrumentor.uninstrument_app(app) |
There was a problem hiding this comment.
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.
@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. |
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. |
💡 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.37+, instrumentation 0.58b0+, the first release with the runtime metric names).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, so app requests carry no trace context.db.client.operation.duration. SQLAlchemy spans are not counted, because they wrap a driver span. Only bounded attributes; never the statement.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(...)). 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(50 tests; each review fix has a test that failed before it).ruff,mypy(baseline) andpython -W error -c "import posthog"clean. Full suite: 5668 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.process.*andcpython.gc.*metrics, appliedbefore_send, sent no vendor OTLP headers and added notraceparent. urllib, urllib3, aiohttp-client and FastAPI were checked the same way (status code andhttp.routekept, notraceparent).📝 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