feat/mrr-pivot
needs attentionviewing older commit8f9031b · fullpre-PRreviewed 2026-08-07 23:08 UTC3H · 8M · 9L · 1I- Purpose
- Out-of-band PostHog insight creator — mirrors posthog-annotate.mjs pattern. The PostHog Terraform provider has no insight_variable resource, so interactive pivot insights (driven by HogQL SQL variables) cannot be managed in infra/posthog. This script is the codified, re-runnable home.
- Goal
- Create and maintain an interactive MRR pivot table on the Revenue & MRR dashboard (prod project 334265) with group-by and filter dropdowns driven by HogQL SQL variables.
- Sub-goals
- Create/reuse 5 PostHog insight variables (2 group-by dropdowns + 3 free-text filters)
- Build and register a DataVisualizationNode table insight with idempotent PATCH/POST
- Split MRR by product taxonomy: Monitoreo (recurring) vs Descarga única (one-time) vs Payment status
- What
- Added scripts/posthog-mrr-pivot.mjs (194 lines). No other files changed.
- Why
- BAT-308 follow-up: expose recurring vs one-time MRR split (~74% recurring, hidden by old descargas/subs framing). ~$116.5k on-platform MRR now legible by product × org × plan.
- Areas
- scripts/posthog-mrr-pivot.mjs+194−0
- Blast
- 1 file, +194 lines. PostHog-only — no production app code affected.
Findings · 20
correctness9
Variable pagination: only page 1 fetched — existing variables silently missed
scripts/posthog-mrr-pivot.mjs:59
ensureVariables() calls GET /insight_variables/?page=1 only. If >1 page exists, the idempotency guarantee breaks — duplicate variables are created on re-runs. Fix: paginate until next is null, or use ?limit=200.
Insight search: missing .ok guard — HTTP error causes silent duplicate creation
scripts/posthog-mrr-pivot.mjs:163
const found = await (await api(...)).json() does not check r.ok. A 4xx/5xx response causes .json() to throw or return unexpected shape, and the script then POSTs a duplicate insight.
variablesMap keyed by variable ID — PostHog may expect code_name as the map key
scripts/posthog-mrr-pivot.mjs:145
variablesMap is { [v.id]: { code_name, variableId } }. If PostHog expects { [code_name]: { variableId } }, the variables are silently unresolved in the insight. Verify the API contract.
argMax composite key: non-deterministic on same-second ties
scripts/posthog-mrr-pivot.mjs:88
argMax(credits_included, (billing_period_start, created_at)) is non-deterministic when two rows have identical billing_period_start AND created_at. Add row id as tiebreaker.
Descarga única: 30-day window may misclassify long-term subscribers as one-time
scripts/posthog-mrr-pivot.mjs:120
An org with zero downloads in last 30 days gets all MRR attributed to Descarga única even if they are a long-term subscriber. Consider a longer window or subscription-status-based classification.
Payment status MRR formula has undocumented magic constant 0.5
scripts/posthog-mrr-pivot.mjs:122
pay_n * 0.5 * rate / 100.0 uses 0.5 with no comment. Name it PAYMENT_STATUS_CREDIT_COST and document what it represents.
Descarga única and Monitoreo business logic undocumented for zero-download case
scripts/posthog-mrr-pivot.mjs:119
The zero-download case assigns 100% to Descarga única — technically sums to mrr but semantically wrong for inactive subscribers. Add a comment documenting the intent.
PATCH body includes dashboards array — may overwrite manual dashboard associations
scripts/posthog-mrr-pivot.mjs:155
PATCH with dashboards: [DASHBOARD_ID] may overwrite the full list if the insight was manually added to additional dashboards. Verify PATCH semantics or omit dashboards from the PATCH body.
LIMIT 300 may silently truncate large org×product×plan breakdowns
scripts/posthog-mrr-pivot.mjs:136
Truncated rows are the smallest MRR contributors; UI totals would be understated without warning. Add a comment noting this is a summary tool with a hard cap.
security3
HogQL injection via free-text String filter variables
scripts/posthog-mrr-pivot.mjs
Filter variables are String type — free-text user input interpolated into HogQL. If PostHog does not parameterize them, injection is possible. Internal-only (founders), low exploitability. Confirm PostHog's interpolation is parameterized.
SSRF via POSTHOG_HOST environment variable
scripts/posthog-mrr-pivot.mjs:3
HOST is read from env without validation. A compromised .env could redirect API calls including the bearer token to an attacker-controlled host.
POSTHOG_PROJECT_ID injected into URL path without numeric validation
scripts/posthog-mrr-pivot.mjs:2
PROJECT is interpolated into all API URLs. Validate it is numeric before use.
conventions4
Pagination: only first page of insight variables fetched
scripts/posthog-mrr-pivot.mjs:59
Same as correctness finding. Idempotency breaks if >1 page of variables exists.
r.text() in error path may throw — reference script uses .catch(() => '')
scripts/posthog-mrr-pivot.mjs:68
Wrap body reads in .catch(() => '') to prevent secondary failures from hiding the original error status code.
Variable naming diverges from reference script (KEY vs API_KEY, PROJECT vs PROJECT_ID)
scripts/posthog-mrr-pivot.mjs:37
posthog-annotate.mjs uses API_KEY and PROJECT_ID. Aligning has zero functional cost.
Log messages unprefixed — reference script uses [posthog-annotate] prefix
scripts/posthog-mrr-pivot.mjs:61
Add [posthog-mrr-pivot] prefix to all console output to match established convention.
tests1
buildQuery() is pure but untested — HogQL logic errors are silent
scripts/posthog-mrr-pivot.mjs
Not required given live validation and script conventions, but worth tracking if the query grows more complex.
improvement3
Missing .ok check before .json() on insights search response
scripts/posthog-mrr-pivot.mjs
Same as correctness finding. All other error paths check r.ok; this call site is the exception.
HOST not stripped of trailing slash — double-slash URLs if POSTHOG_HOST ends with /
scripts/posthog-mrr-pivot.mjs:3
posthog-annotate.mjs applies .replace(/\/$/, '') to HOST. Apply the same one-liner.
'insight updated' logged before res.ok check — misleading on PATCH failure
scripts/posthog-mrr-pivot.mjs
Move the success log after the res.ok guard so the operator does not see a success message followed by an error.