Skip to content

feat(replay): emit replay and replay vision metrics via otlp - #70535

Merged
arnohillen merged 2 commits into
masterfrom
ahillen/replay-otel-metrics
Jul 14, 2026
Merged

feat(replay): emit replay and replay vision metrics via otlp#70535
arnohillen merged 2 commits into
masterfrom
ahillen/replay-otel-metrics

Conversation

@arnohillen

Copy link
Copy Markdown
Contributor

Problem

Session replay and replay vision are well instrumented in Prometheus/Grafana, but none of that signal reaches the PostHog Metrics product. We can't watch our own pipelines' health inside PostHog, can't correlate operational metrics with logs and traces there, and we're not dogfooding Metrics from the replay side at all. A few high-value signals also don't exist anywhere yet, notably quota exhaustion and credit burn for replay vision (both launch-relevant for billing) and ingestion end-to-end lag for session replay.

Changes

Every mirrored metric keeps its exact Prometheus name and label set, following the convention the logs team established with logs_ingestion_* twins, so dashboards translate 1:1 between sinks. Emission is off unless OTEL_METRICS_EXPORT_URL and OTEL_METRICS_EXPORT_TOKEN are set (same env pair the Node exporter already uses), so nothing changes for deployments that don't opt in.

flowchart LR
    RV[replay vision temporal activities] --> H[record_* helpers]
    PB[playback API] --> H
    H --> PROM[Prometheus registry / Grafana]
    H --> PY[posthog/otel_metrics.py]
    ING[blob ingestion v2 + rasterizer] --> NPROM[prom-client / Grafana]
    ING --> NOTEL[OTel twins]
    PY --> CL[capture-logs /i/v1/metrics]
    NOTEL --> CL
    CL --> MP[PostHog Metrics product]
    classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff;
    classDef phYellow fill:#f9bd2b,stroke:#f9bd2b,color:#000;
    classDef phGray fill:#e5e7eb,stroke:#c7ccd1,color:#000;
    class H,PY,NOTEL phBlue;
    class MP phYellow;
    class PROM,NPROM,CL phGray;
Loading

New shared infrastructure

  • posthog/otel_metrics.py: a Python twin of nodejs/src/common/metrics/otel-metrics.ts. Lazy, per-process (PID-keyed) provider init so preforking servers (gunicorn, celery) get a live exporter thread per worker, and a OtelInstrumentFactory with record_*_twin methods that derive the OTLP metric's name and description from the Prometheus instrument itself, so the two sinks can't drift. Twin recording swallows all errors.
  • Settings OTEL_METRICS_EXPORT_URL / OTEL_METRICS_EXPORT_TOKEN / OTEL_METRICS_EXPORT_INTERVAL_MS, matching the Node names so one env pair configures both runtimes.

Replay vision (products/replay_vision/backend/temporal/metrics.py now exposes dual-emit record_* helpers used by all call sites)

Mirrored: replay_vision_observations_total, replay_vision_failure_kinds_total, replay_vision_ineligible_kinds_total, replay_vision_activity_duration_seconds, replay_vision_provider_call_seconds.

New (Prometheus + OTLP):

Metric Why
replay_vision_quota_exhausted_skips_total{scanner_type} Quota exhaustion previously only logged and silently skipped the observation
replay_vision_credits_consumed_total{scanner_type, model} Real-time credit burn rate, gated on receipt creation so retries can't double count
replay_vision_sweep_outcomes_total{outcome} + replay_vision_sweep_candidates_total Throttled/no-candidates/found sweep ticks, recorded in activities because workflow code can't emit metrics
replay_vision_observation_e2e_seconds{scanner_type} Creation-to-success wall time including queueing, rasterization, upload, and the provider call
replay_vision_side_effect_failures_total{effect} The fail-soft post-success side effects (event, embed, tags, signal) swallow errors, so degradation was invisible
replay_vision_gemini_cleanup_backlog Tracked Gemini files awaiting cleanup; growth means the sweep is losing

Session replay ingestion (Node, otel-metrics.ts twin module wired inside SessionBatchMetrics so no call sites changed)

Mirrored: sessions/events flushed, bytes written, all drop counters (missing retention, restrictions, rate limits, blocklist), S3 upload errors/timeouts/latency. New: recording_blob_ingestion_v2_e2e_lag_seconds, per-session staleness at flush (wall clock minus the newest flushed event timestamp), which is what "why can't I see my recording yet" tickets are about.

Rasterizer: mirrors recording_rasterizer_activities_total, recording_rasterizer_activity_duration_seconds, recording_rasterizer_errors_total. It gets its own env-driven initMetrics because the shared one evaluates defaultConfig at import, which throws without Postgres env vars the rasterizer deployment doesn't have.

Playback API (Python): the snapshot stage histograms (gather sources, fetch blocks, stream to client) and the throttle counter now dual-emit through two small helpers.

Note

No behavior changes when the env vars are unset: all Prometheus metrics keep identical names and labels, and OTLP recording is a no-op against a noop meter.

How did you test this code?

Automated tests, all run locally:

  • posthog/test/test_otel_metrics.py (new): catches gating inversion (unconfigured must be a safe no-op), provider construction breakage against the real SDK signatures, and the _total suffix restoration in counter twins, which prometheus_client strips internally and would silently rename every mirrored counter.
  • products/replay_vision/backend/tests/test_metrics.py (new): one parameterized call per record_* helper. These run inside activity error handlers where a prom label mismatch raises at runtime, and no existing test exercises the new helpers.
  • posthog/session_recordings/test/test_snapshot_stage_metrics.py (new): the timed-stage context manager must still observe when the body raises, and must propagate the exception.
  • nodejs/src/ingestion/pipelines/sessionreplay/otel-metrics.test.ts (new): mirrors the logs twin test. Locks in the lazy-instrument startup order, name/attribute emission, zero-count skipping, negative-lag clamping, prom bucket parity, and that a throwing OTel SDK is swallowed (in production those errors are invisible by design, so a test is the only place they surface).
  • Existing suites: 1019 replay vision backend tests + throttle tests pass, 371 session replay ingestion Jest tests pass (one test mock extended for the new observeE2eLag), full nodejs typecheck and lint pass. Two pre-existing local failures (*.integration.test.ts needing a local test_persons DB, and a storage.test.ts logger flake) reproduce identically on master without this change.

Not done: no end-to-end verification against a live capture-logs ingest; the export path is exercised through the SDK pipeline with a capturing exporter instead.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Docs update

Internal operational instrumentation only, no user-facing docs affected. The new env vars are deployment config wired via charts.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Authored with Claude Code (Claude Fable 5) in an interactive session directed by the assignee. Skills invoked: /writing-tests, /query-clickhouse-via-metabase (used to confirm no replay metrics exist in the Metrics product yet in either region before building).

Decisions along the way:

  • Followed the existing logs-team convention of 1:1 Prometheus-name twins instead of inventing new dot-separated metric names, so dashboards translate directly.
  • Sweep outcome metrics live in the sweep's activities rather than the workflow, since Temporal workflow code is deterministic and can't emit metrics; the throttle decision is mirrored from the workflow's headroom formula with a comment marking that.
  • A first version had the rasterizer reuse the shared Node initMetrics; review caught that it evaluates defaultConfig at import and throws without Postgres env vars, which would have crashed the rasterizer at boot, so it got a self-contained env-driven init instead.
  • A first version duplicated each metric's name and description strings between the Prometheus definition and the OTLP call; refactored to record_*_twin methods that derive both from the Prometheus instrument, with the counter _total suffix restoration handled once and locked by a test.

🤖 Generated with Claude Code

Dual-emits the headline session replay and replay vision operational metrics
into the PostHog Metrics product alongside the existing Prometheus
instrumentation, and adds new metrics for quota exhaustion, credit burn,
sweep outcomes, observation end-to-end latency, side effect failures,
Gemini cleanup backlog, and ingestion end-to-end lag.

Emission is gated off unless OTEL_METRICS_EXPORT_URL and
OTEL_METRICS_EXPORT_TOKEN are set, so nothing changes for deployments that
don't opt in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@arnohillen arnohillen added the stamphog Request AI approval (no full review) label Jul 13, 2026
@arnohillen arnohillen self-assigned this Jul 13, 2026
@arnohillen
arnohillen requested review from a team, TueHaulund, fasyy612 and ksvat and removed request for a team July 13, 2026 17:37
@trunk-io

trunk-io Bot commented Jul 13, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic BadgeStatic Badge

Failed Test Failure Summary Logs
Test execution failure: could be caused by test hooks like 'afterAll'. The test failed due to a socket hang-up error, indicating a network connection was unexpectedly closed. Logs ↗︎

View Full Report ↗︎Docs

Applies the findings from an 8-angle review pass:

- Histogram twins now derive bucket boundaries from the prom instrument
  (the playback twins previously fell back to OTel's default advisory
  buckets, breaking 1:1 dashboard parity), and twin metadata is memoized
  per instrument instead of re-derived per record.
- Provider invalidation is unified on an epoch counter covering both fork
  and test reset (reset previously left factory caches bound to a dead
  provider).
- The sweep headroom formula is defined once in constants and shared by
  the workflow decision and the throttled metric.
- Signal side-effect failures count per activity attempt, comparable to
  the other effects.
- Node: shared createOtlpMeterProvider, exemplar-wrapping instrument
  factories, and the swallowing wrapper move to common/metrics, used by
  the logs, session replay ingestion, and rasterizer twin modules (the
  rasterizer twins gain exemplar support).
- The e2e lag value is sanitized once at the compute site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@arnohillen
arnohillen marked this pull request as ready for review July 14, 2026 15:25
@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested review from a team July 14, 2026 15:25
@stamphog

stamphog Bot commented Jul 14, 2026

Copy link
Copy Markdown

Note

🤖 stamphog reviewed 30e47df46f8df4710b14db4b6a6b3b7be062cd69 — verdict: REFUSED

Gates denied for size (over the 800L ceiling), this is a cross-team change lacking a landed review, and greptile-apps and hex-security-app both have in-flight reviews (👀) that haven't completed — should not approve over active review.

  • Author wrote 0% of the modified lines and has 19 merged PRs in these paths (familiarity MODERATE).
  • Gate DENIED: too large for auto-review (1028L/29F substantive vs 800L ceiling)
  • Cross-team change (replay + ingestion) with no independent review landed
  • In-flight reviews from greptile-apps[bot] and hex-security-app[bot] (👀) not yet resolved
Gate mechanics and policy version
Gate Result
prerequisites all clear
deny-list no deny categories matched
size too large for auto-review (1028L, 29F substantive, 1415L/33F incl. docs/generated/snapshots — ceiling is 800L)
tier T1-agent / T1d-complex (1415L, 33F, cross-cutting, feat)
stamphog 2.0.0b3 .stamphog/policy.yml @ a7ea91d · reviewed head 30e47df

@stamphog stamphog Bot removed the stamphog Request AI approval (no full review) label Jul 14, 2026
@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "refactor(replay): harden and dedupe the ..." | Re-trigger Greptile

url,
token,
serviceName: process.env.OTEL_SERVICE_NAME ?? 'recording-rasterizer',
exportIntervalMillis: parseInt(process.env.OTEL_METRICS_EXPORT_INTERVAL_MS ?? '60000', 10),

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 Malformed Interval Crashes Startup

When OTEL_METRICS_EXPORT_INTERVAL_MS is set to an empty or non-numeric value, parseInt returns NaN and this value is passed into the metric reader during rasterizer module startup. Because initMetrics() runs before the worker's startup error handler, a bad optional metrics env var can prevent the rasterizer worker from booting.

Suggested change
exportIntervalMillis: parseInt(process.env.OTEL_METRICS_EXPORT_INTERVAL_MS ?? '60000', 10),
exportIntervalMillis: Number.isFinite(parseInt(process.env.OTEL_METRICS_EXPORT_INTERVAL_MS ?? '60000', 10))
? parseInt(process.env.OTEL_METRICS_EXPORT_INTERVAL_MS ?? '60000', 10)
: 60000,

Comment on lines +152 to +154
if receipt_created:
# Gate on the receipt so a lost-result retry can't double count the burn rate.
record_credits_consumed(inputs.scanner_type, model, credits)

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 Committed Receipts Lose Metrics

If this activity commits the usage receipt and then the worker crashes or Temporal loses the activity result before these metric calls run, the retry finds the observation already terminal and returns before emitting credit burn. Billing state is durable, but the new replay_vision_credits_consumed_total signal can permanently miss that usage.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

Playwright — all passed

All tests passed.

View test results →

⚠️ Backend coverage — 95.0% of changed backend lines covered — 14 uncovered

🧪 Backend test coverage

Patch coverage — changed backend lines (products + core): ███████████████████░ 95.0% (299 / 313)

File Patch Uncovered changed lines
products/replay_vision/backend/temporal/activities/count_in_flight_applies.py 75.0% 50
products/replay_vision/backend/temporal/activities/call_scanner_provider.py 80.0% 344
products/replay_vision/backend/temporal/activities/emit_observation_signal.py 85.7% 113
posthog/otel_metrics.py 92.3% 163–164, 172–173, 176–180
posthog/session_recordings/session_recording_api.py 92.3% 795
posthog/test/test_otel_metrics.py 98.4% 29

🤖 Agents: add a test covering the lines above, or note why under "How did you test this code?". Machine-readable gap list: the patch-coverage artifact on this run (gh run download 29345452673 -n patch-coverage), or the coverage-data block at the end of this comment.

Per-product line coverage (touched products)
Product Coverage Lines
platform_features ██░░░░░░░░░░░░░░░░░░ 12.1% 7 / 58
batch_exports ████████░░░░░░░░░░░░ 39.6% 8,347 / 21,104
demo ███████████░░░░░░░░░ 55.2% 1,436 / 2,601
warehouse_sources_queue ████████████░░░░░░░░ 59.2% 148 / 250
tasks █████████████░░░░░░░ 66.7% 24,376 / 36,570
data_tools ██████████████░░░░░░ 70.0% 63 / 90
ai_gateway ███████████████░░░░░ 75.0% 9 / 12
data_modeling ████████████████░░░░ 78.4% 4,696 / 5,987
signals ████████████████░░░░ 78.5% 18,072 / 23,028
cdp ████████████████░░░░ 80.6% 3,105 / 3,851
wizard ████████████████░░░░ 82.5% 772 / 936
cohorts █████████████████░░░ 82.9% 3,048 / 3,675
notebooks █████████████████░░░ 83.8% 6,086 / 7,259
agent_platform █████████████████░░░ 84.1% 3,095 / 3,678
actions █████████████████░░░ 86.6% 717 / 828
engineering_analytics █████████████████░░░ 87.3% 4,269 / 4,889
product_tours █████████████████░░░ 87.5% 1,266 / 1,447
exports ██████████████████░░ 88.3% 6,857 / 7,763
visual_review ██████████████████░░ 88.5% 5,565 / 6,287
business_knowledge ██████████████████░░ 88.5% 4,400 / 4,969
conversations ██████████████████░░ 88.9% 15,924 / 17,921
mcp_analytics ██████████████████░░ 89.1% 2,485 / 2,790
dashboards ██████████████████░░ 89.1% 5,650 / 6,342
error_tracking ██████████████████░░ 89.2% 9,473 / 10,615
streamlit_apps ██████████████████░░ 90.4% 2,499 / 2,764
slack_app ██████████████████░░ 90.6% 9,460 / 10,444
links ██████████████████░░ 90.6% 183 / 202
marketing_analytics ██████████████████░░ 90.7% 11,476 / 12,646
product_analytics ██████████████████░░ 91.2% 5,652 / 6,195
managed_migrations ██████████████████░░ 91.9% 908 / 988
workflows ██████████████████░░ 92.0% 4,795 / 5,210
mcp_store ██████████████████░░ 92.1% 3,665 / 3,981
data_warehouse ██████████████████░░ 92.1% 17,282 / 18,765
alerts ██████████████████░░ 92.1% 3,389 / 3,678
web_analytics ███████████████████░ 92.7% 13,702 / 14,787
notifications ███████████████████░ 92.7% 1,026 / 1,107
ai_observability ███████████████████░ 92.7% 14,670 / 15,822
surveys ███████████████████░ 92.9% 5,660 / 6,094
posthog_ai ███████████████████░ 93.2% 1,311 / 1,407
tracing ███████████████████░ 93.2% 2,423 / 2,599
approvals ███████████████████░ 93.3% 3,395 / 3,640
reminders ███████████████████░ 93.4% 468 / 501
early_access_features ███████████████████░ 93.8% 848 / 904
legal_documents ███████████████████░ 94.1% 1,568 / 1,667
endpoints ███████████████████░ 94.1% 8,606 / 9,143
messaging ███████████████████░ 94.3% 2,366 / 2,508
skills ███████████████████░ 94.4% 2,819 / 2,987
revenue_analytics ███████████████████░ 94.4% 3,586 / 3,797
growth ███████████████████░ 94.9% 2,393 / 2,522
logs ███████████████████░ 95.3% 9,429 / 9,895
experiments ███████████████████░ 95.6% 24,057 / 25,166
replay_vision ███████████████████░ 95.6% 12,785 / 13,369
feature_flags ███████████████████░ 96.1% 14,533 / 15,127
warehouse_sources ███████████████████░ 96.1% 212,352 / 221,024
annotations ███████████████████░ 96.2% 732 / 761
user_interviews ███████████████████░ 96.4% 2,242 / 2,325
access_control ███████████████████░ 96.8% 849 / 877
customer_analytics ███████████████████░ 97.3% 7,277 / 7,481
data_catalog ███████████████████░ 97.3% 1,288 / 1,324
analytics_platform ████████████████████ 98.2% 2,098 / 2,137
metrics ████████████████████ 98.3% 2,363 / 2,403
live_debugger ████████████████████ 99.2% 613 / 618
field_notes ████████████████████ 99.4% 158 / 159

Report-only. Patch coverage = changed backend lines covered vs origin/master. Sorted lowest first.
Known gaps: lines covered only by Temporal tests show as uncovered; core line numbers may drift if master changed the same file.

@DanielVisca
DanielVisca self-requested a review July 14, 2026 15:57

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

I'm adding to the sdk for node so it will be easier to instrument in the future. This looks good for OTLP

@arnohillen
arnohillen merged commit b3068b5 into master Jul 14, 2026
438 of 448 checks passed
@arnohillen
arnohillen deleted the ahillen/replay-otel-metrics branch July 14, 2026 19:41
@deployment-status-posthog

deployment-status-posthog Bot commented Jul 14, 2026

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-07-14 20:07 UTC Run
prod-us ✅ Deployed 2026-07-14 20:31 UTC Run
prod-eu ✅ Deployed 2026-07-14 20:34 UTC Run

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