PostHog durable stream cost investigation — 2026-07-18
Status: mitigated, with the complete Group Analytics model prepared on this branch. Production keeps the complete durable feed. Local and preview exports are disabled by application code, and a reversible staging ingestion transformation protects existing preview deployments while they update.
Decision#
- Keep every non-ephemeral production stream fact in PostHog.
- Associate every production stream fact with its
projectgroup. Do not read the project directory merely to derive an organization for the machine feed. Accept identified-event and Group Analytics processing for this feed as an intentional product cost. - Identify signed-in browser users as people. Use Group Analytics for their UI
activity and curated product events, with
organizationandprojectas the group types. - Do not create a
usergroup. The identified person already is the user. - Do not export the synthetic durable feed from local development or preview deployments.
- Keep the staging
Drop Eventstransformation enabled until every live preview deployment has the application-side production gate. - Treat the production error and parked-subscription events found during this audit as product defects to follow up, not as analytics noise.
The emergency code change is PR #2119.
Executive summary#
The spike was not caused by event payload size, PostHog retries, or one runaway stream. It was the multiplicative effect of exporting the full synthetic stream history generated by CI across all nine preview workers.
During the fixed 24-hour window from 2026-07-17 07:12 UTC to 2026-07-18 07:12 UTC:
| PostHog project | All events | stream:append |
Stream share | Relative stream volume |
|---|---|---|---|---|
Production (iterate, 115112) |
42,778 | 41,331 | 96.62% | 1.00× |
Development (iterate, 116602) |
21,325 | 21,116 | 99.02% | 0.51× |
Staging (iterate, 116603) |
2,895,197 | 2,798,394 | 96.66% | 67.71× |
The staging rate projects to 86.9 million events per 30-day month if sustained. Using PostHog's public July 2026 Product Analytics tiers, the base analytics charge alone projects to about $2,332/month, or $77.73/day. Because the stream events currently create person profiles, identified-event processing projects to another roughly $2,902.20/month, or $96.74/day. Group Analytics can add a third charge. The actual invoice depends on organization-wide tier allocation, enabled add-ons, discounts, and the partial month; the API available to this investigation does not expose the invoice.
The post-fix production feed ran at about 42,944 stream events/day, or 1,288,320 events per 30-day month. At PostHog's 2–15 million event marginal rates, that is about $44.19/month for base Product Analytics alone. Treating every row as identified raises the same volume to about $133.99/month; Group Analytics raises it to about $172.63/month. If this feed were the organization's only volume and therefore crossed the first-million-free boundary itself, the corresponding estimates would be $14.42, $71.50, and $91.97.
At exactly 40,000 grouped events/day (1.2 million/month), the combined public price is approximately $63.80/month when the organization's one-million free allowances are otherwise unused. If other traffic has already consumed those allowances and this feed occupies the next million's higher marginal band, the same feed is approximately $345.80/month. If all of it lands in the 2–15 million marginal band, it is approximately $160.80/month. Grouped 40k/day therefore does not intrinsically cost $300; the organization's prior usage determines which tier each row occupies. The preview flood distorted the current month by consuming the cheap bands before production traffic reached them.
For the 41,331 production stream rows in the fixed 24-hour window, the 2–15 million marginal costs are approximately $1.42 base-only, $4.30 with identified event processing, or $5.54 with both identified and group processing. The number “41k” is an event count, not a £41,000 charge.
PostHog bills Product Analytics by captured event count, not JSON bytes. The large GitHub webhook payloads explain storage and query weight, but not the event-count spike.
What “used person profiles” means#
This PostHog invoice line is driven by the number of identified events processed, not by the number of unique people stored. The fixed production window used only 107 stable synthetic project distinct IDs, but all 41,331 stream rows were associated with those profiles. That is why the usage looked like tens of thousands of “profiles” even though there were not tens of thousands of users.
The synthetic project IDs therefore remain intentional identified profiles.
Every production stream event uses the same stable synthetic identity for its
project and carries only PostHog's project $groups mapping. The exporter
does not read the project directory to derive an organization for machine
traffic. This is the cost required for the raw durable facts to participate in
project-level funnels, cohorts, breakdowns, flags, and experiments.
Signed-in web UI events are different. They should have a real person profile
so funnels, cohorts, person properties, flags, and cross-session behavior can
use the authenticated user. The browser now calls identify(user.id, …) and
then attaches organization and project groups. It resets identity on
sign-out and clears/replaces groups when route context changes.
Timeline#
| Time (UTC) | Event |
|---|---|
| 2026-07-17 12:54 | Durable PostHog feed merged in commit 153aa5d; production deployment followed at about 12:56. |
| 2026-07-17 13:00–15:02 | Initial subscription included ephemeral events. Production stored 10,119 ephemeral rows, predominantly LLM response chunks. |
| 2026-07-17 15:02 | PR #2079 merged: subscriptions use includeEphemeral: false, plus capture-side filtering for pre-existing subscriptions. |
| 2026-07-17 16:00 onward | Zero ephemeral stream rows were stored in production, development, or staging. |
| 2026-07-18 07:12 | Investigation measured 2.80 million staging stream events in the trailing day and an observed run rate around 152,400/hour. |
| 2026-07-18 07:21:40 | Enabled staging transformation 019f741a-2dd8-0000-61e9-73abda6505fe, Emergency: drop preview stream:append. |
| 2026-07-18 07:21:43 | Last staging stream:append event stored. Repeated checks found zero later rows and no transformation warnings or errors. Production continued ingesting exclusively from os-prd. |
| 2026-07-18 07:54 | PR #2119's preview deployment and E2E suite passed. Its test project's PostHog subscription reached offset 25 with zero lag, retries, parked offset, or last error; preview telemetry contained zero capture spans or PostHog-related errors. |
| 2026-07-18 08:04 | PR #2119 merged after all checks and review gates passed. |
Why staging was so large#
Every preview slot contributed substantial volume. This rules out a single bad worker and points to shared CI behavior.
| Worker | Events | Projects | Project/stream pairs |
|---|---|---|---|
os-preview-9 |
425,981 | 4,580 | 41,302 |
os-preview-3 |
395,067 | 3,985 | 36,108 |
os-preview-7 |
389,150 | 4,167 | 37,451 |
os-preview-5 |
345,936 | 3,373 | 30,260 |
os-preview-6 |
345,193 | 3,797 | 34,054 |
os-preview-4 |
271,234 | 2,807 | 25,381 |
os-preview-8 |
254,873 | 2,770 | 25,146 |
os-preview-1 |
237,978 | 2,637 | 23,733 |
os-preview-2 |
132,982 | 1,417 | 12,785 |
No individual preview project dominated the staging project. The five largest were:
| Worker | Project | Events | Streams |
|---|---|---|---|
os-preview-8 |
prj_1d1aa28d… |
2,297 | 232 |
os-preview-3 |
prj_16cc00d8… |
1,699 | 9 |
os-preview-4 |
prj_7ff54263… |
1,614 | 12 |
os-preview-4 |
prj_9d1204b0… |
1,440 | 9 |
os-preview-4 |
prj_ada27c00… |
1,378 | 9 |
The largest accounted for only 0.08% of staging stream volume. The largest individual project/stream pairs were likewise recognizable E2E fixtures, and every stored row had a distinct event UUID:
| Worker | Project | Stream | Events | Unique event UUIDs |
|---|---|---|---|---|
os-preview-3 |
prj_16cc00d8… |
/agents/e2e-script-spill |
1,599 | 1,599 |
os-preview-4 |
prj_04ec3dfb… |
/agents/slack/main-slack/… |
782 | 782 |
os-preview-7 |
prj_31e9410d… |
/agents/slack/main-slack/… |
750 | 750 |
os-preview-4 |
prj_7ff54263… |
/sandboxes/worker-builder |
748 | 748 |
os-preview-6 |
prj_4acbab84… |
/agents/slack/main-slack/… |
724 | 724 |
The largest staging event types were lifecycle fan-out:
| Stream event type | Events |
|---|---|
stream/subscription-configured |
745,146 |
stream/child-stream-created |
395,367 |
stream/woken |
309,080 |
stream/connection-opened |
308,572 |
stream/created |
266,311 |
stream/connection-closed |
97,981 |
repo/created |
64,099 |
repo/ready |
61,536 |
Root streams, /repos/config, /scheduler/primary, and /integrations/email
were the most common paths on multiple workers. The distribution matches
preview E2E fixtures creating projects and their standard child streams. It
does not match one stream appending the same fact indefinitely.
Development was much smaller: one local worker (os) produced 21,116 stream
events across 209 projects and 1,649 project/stream pairs. It is still synthetic
data with no production analytics value, so the same production-only gate
applies. Its largest project (prj_4bfdd6bb…) contributed 676 events across 19
streams, and its largest individual stream was a different project's root with
420 events. All 420 had distinct event UUIDs, so development also showed fixture
fan-out rather than a retry loop.
Production attribution#
After the ephemeral fix, from 2026-07-17 15:10 UTC through 2026-07-18 07:12 UTC, production stored 28,689 stream events. The top projects were:
| Project | Events | Streams | Original JSON bytes |
|---|---|---|---|
iterate before production recreation (prj_d08f…) |
12,978 | 70 | 152,604,614 |
iterate after production recreation (prj_d985…) |
3,499 | 50 | 31,276,139 |
nustom |
1,562 | 20 | 1,664,672 |
templestein |
721 | 10 | 987,608 |
agent-fleet |
398 | 20 | 554,217 |
garple |
303 | 7 | 373,867 |
The production recreation at 2026-07-18 05:31 UTC explains the two Iterate project IDs; they are one logical project across the reset.
The old Iterate GitHub installation stream was the largest individual stream: 7,465 events and 145,898,898 original bytes after the ephemeral fix. The largest individual streams were:
| Project | Stream | Events | Unique event UUIDs | Original JSON bytes |
|---|---|---|---|---|
Old iterate |
/integrations/github/install-115079265 |
7,465 | 7,465 | 145,898,898 |
Current iterate |
/integrations/github/install-115079265 |
1,158 | 1,158 | 22,697,634 |
Old iterate |
/ |
1,093 | 1,093 | 1,914,345 |
nustom |
/agents/telegram/nustombot/… |
813 | 813 | 1,162,536 |
Old iterate |
/repos/config |
580 | 580 | 884,647 |
The UUID equality in this table is further evidence that these were distinct durable facts, not duplicate capture retries.
GitHub webhooks were the largest event type by bytes and count:
| Stream event type | Events | Original JSON bytes |
|---|---|---|
github/webhook-received |
8,440 | 174,716,705 |
stream/woken |
6,547 | 1,219,238 |
stream/subscription-configured |
2,328 | 965,524 |
stream/connection-opened |
2,175 | 7,476,991 |
stream/child-stream-created |
1,429 | 344,264 |
repo/cloudflare-artifact-event-received |
1,252 | 1,076,110 |
This is useful production telemetry, so it remains enabled.
Payload size#
For the 28,689 post-fix production rows:
| Statistic | Original JSON bytes |
|---|---|
| Average | 6,729 |
| p50 | 567 |
| p95 | 32,279 |
| p99 | 54,284 |
| Maximum | 335,859 |
| Total | 193,049,578 |
3,231 rows (11.26%) exceeded 16 KiB. This matters for Cloudflare Workers Analytics Engine even though it does not affect PostHog event-count billing.
Nine events exceeded the application's 100 KiB boundary and were deterministically truncated before capture. Their original size and truncation flag remain indexed. Payload size deserves storage/query monitoring, but shrinking these objects would not materially change Product Analytics event-count billing.
Cloudflare ClickHouse alternative#
Workers Analytics Engine is Cloudflare's ClickHouse-backed analytics product, not a general ClickHouse database. At this volume its proposed included write allowance would cover the feed, and its SQL API is useful for aggregate operational telemetry. It is not an exact replacement for this feed: it has a 16 KiB blob budget, three-month retention, and adaptive sampling that can make individual rare records unavailable. 11.26% of the measured events already exceed its blob ceiling.
An exact Cloudflare-native alternative is to stream raw events through R2 Pipelines into R2/Iceberg and query them with R2 SQL or an external ClickHouse. That is likely inexpensive at the observed data volume, but it means building or operating the segmentation UI, joins, funnels, cohorts, retention queries, and identity model that PostHog supplies.
The practical split is therefore:
- PostHog for curated product analytics, browser identity, organization/project Group Analytics in the browser, and project Group Analytics for durable stream activity.
- Grouped
stream:appendevents while their product value justifies the full base, identified-event, and Group Analytics cost. Use the native project group for project slicing; type and path are promoted event properties (stream_event_type,stream_path) rather than encoded into the event name. - R2/Iceberg as the better future home if exact long-retention raw history is required independently of PostHog.
Runaway-loop and retry checks#
No unbounded ingestion loop was found:
- All 41,331 production stream rows in the fixed 24-hour window had distinct
stream_event_uuidvalues. There were zero duplicate stored UUIDs. - Every one of the 6,547 post-fix
stream/wokenevents had a distinct(project, stream, incarnationId)tuple. Wake bursts aligned with production deploys and Durable Object incarnations, not repeated delivery of one wake fact. - Every post-fix GitHub webhook event had a distinct stream idempotency key.
- Staging volume was distributed across nine workers, thousands of projects, and hundreds of thousands of streams. Per-project counts were consistent with normal project birth and E2E fixture fan-out.
- Ephemeral leakage stopped completely after the revised subscription and capture-side filter deployed. It was a bounded rollout defect, not an ongoing source of the spike.
Deploy-driven stream/woken volume is real operational churn and should be
monitored per deploy, but it is neither a retry storm nor the main staging cost
driver.
Error classification#
The full feed exposed bounded production defects that were previously harder to see:
- 42
stream/error-occurredevents belonged to the pre-recreation Iterate project. They record rollout incompatibilities such as missing worker SDK exports, RPC serialization/version errors, poison-event skips, and processor revival backoff. The last was stored at 23:21 UTC. - Two events in
agent-fleetare current contract violations: support and release agents producedagent/runtime-changedwithoutruntime.runningScripts. These need an owner and regression tests. - 23
stream/subscription-parkedfacts were bounded and loud: 17 on the old Iterate project and two each on three short-lived production test projects. Causes included the rollout incompatibilities, unseeded repositories, a memory-limit reset, and an unavailable commit.
These events do not explain the cost spike and do not show an unbounded loop. They must still be resolved or explicitly modeled under the repository's no-deviant-behavior principle.
Immediate controls and rollback#
Application gate#
capturePosthogStreamEventBatch now sends only when WORKER_SELF === "os-prd".
Non-production deliveries return successfully before calling PostHog, so
existing durable subscriptions advance normally instead of retrying or parking.
Preview acceptance verified that behavior against os-preview-3: the E2E
project's iterate-platform-posthog subscription acknowledged all 25 offsets
with lag: 0, attempt: 0, lastError: null, and parkedAtOffset: null.
Cloudflare telemetry for the deployed version contained zero
posthog.capture_stream_events spans and zero PostHog-related errors.
Rollback: revert PR #2119. This would restore local/preview export, so do not roll it back while their PostHog keys remain configured unless another ingestion filter is already active.
Staging ingestion transformation#
The enabled staging-only PostHog Drop Events transformation is:
- ID:
019f741a-2dd8-0000-61e9-73abda6505fe - Name:
Emergency: drop preview stream events - Filter:
event = 'stream:append' OR event LIKE 'append:%'(legacy dual match; production capture is now onlystream:append) - Execution order:
0 - Function:
return null
PostHog documents that dropped transformation events do not count toward usage or billing. The transformation is reversible, but dropping is permanent for matching events; those staging facts cannot be recovered from PostHog.
Keep it enabled until all preview slots and long-lived PR preview branches have picked up the production-only application gate. Then disable it and run a controlled staging capture check before deletion. Production project 115112 was never modified.
Recommendations#
- Keep full grouped non-ephemeral production coverage. The current post-fix volume is bounded and has already exposed valuable lifecycle and defect evidence. Every row should join its project group.
- Keep dev/preview stream export off. Preview E2E data belongs in test logs and traces, not paid organization analytics. Ordinary browser analytics can remain separated in the existing development/staging projects.
- Create daily event-volume alerts per PostHog project. Alert on both total
events and
stream:append, with breakdowns by native project group slug,stream_path, andstream_event_type. A production warning at 50,000/day and intervention at 60,000/day leaves headroom below $20/day at the highest first-paid combined marginal rates. Non-production stream volume should be exactly zero. - Alert on source integrity. Daily checks should assert zero ephemeral
rows, zero duplicate top-level event UUIDs, and bounded delay between the
source
timestampand PostHog'screated_at. - Use
created_atfor billing investigations. The eventtimestampis the durable source commit time and can be old when a backlog is delivered;created_atis the PostHog ingestion time that explains the bill. - Use groups on every production event. A browser person is the
authenticated user. A machine-authored stream fact uses one stable synthetic
identity per project and carries only
projectgroup context. Browser events carry both organization and project context where available. - Classify the production error rows. Fix the two current
runtime.runningScriptscontract violations and track the old rollout/parked cases to explicit resolutions. Do not normalize them as expected noise. - Keep Group Analytics for all production activity. The product model needs organization- and project-level funnels, cohorts, flags, experiments, and breakdowns in the browser, plus project-level analysis across browser and durable stream events. Accept its identified-event charge, and track the add-on line item so a future volume increase cannot silently change the economics again.
- Treat billing limits as a last-resort circuit breaker. PostHog limits can permanently drop data. Use a warning below the limit, document ownership and rollback, and keep production headroom rather than setting the limit at the normal run rate.
Reproducibility notes#
All PostHog data and operations in this investigation used posthog-cli api.
The environment projects are:
- production: 115112
- development: 116602
- staging/previews: 116603
The standard PostHog event audit also passed its direct-SDK checks: there are no
direct posthog.capture() call sites, automatic pageviews were 2.31% of the
trailing week's production events, and the production project contained no
detectable development or preview workers. Its direct-call checks do not cover
the custom batch-capture path investigated here.
Current PostHog references:
- Product Analytics pricing
- Identified Events and Group Analytics add-ons
- Group Analytics billing
- Anonymous versus identified events
- Event filtering
- Drop Events transformation
- Billing limits and alerts
- Workers Analytics Engine
- Workers Analytics Engine limits
- Workers Analytics Engine sampling
- R2 Pipelines pricing
- R2 SQL pricing