feat(replays): store rrweb chunk payloads in R2, not ClickHouse - #337
Conversation
`session_replay_events` held the raw rrweb payload as an opaque `Events` String. Nothing ever queried inside it — the only reader selects the whole column for one (OrgId, SessionId) and orders by ChunkSeq — so it was a key/value blob fetch paying warehouse storage prices, and it is the bulk of a session's bytes. The ingest gateway now PUTs the browser's gzip verbatim to an S3-compatible bucket and writes a thin index row with an empty `Events`; the API refills the payload on read, so the HTTP response shape is unchanged. `session_events` — the distilled structured stream — deliberately stays in ClickHouse. It is queried across sessions (the event-match semi-join and the active/idle window function), which is what a column store is for. No schema change. An empty `Events` is already an unambiguous "blob-backed" marker, because the SDK returns early on an empty buffer and never uploads a chunk with no events, and the object key derives from (OrgId, SessionId, ChunkSeq). Adding a StorageKey column would have cost a ClickHouse migration, a SCHEMA_VERSION bump (which would route every not-yet-migrated BYO-ClickHouse org's logs/traces/metrics to Tinybird), and the first-ever versioned local-schema migration edge — for a table local mode never reads. Ordering is the invariant: the PUT happens before the row is enqueued, so a row can never point at a missing object. A failed PUT returns non-2xx and the SDK drops the chunk, which it already does without retrying. The bucket lifecycle expires objects at 32 days against the table's 30-day TTL, so the row always disappears first — a session that lists as recorded but plays back empty is the one failure with no good client-side handling. Ships dark and degrades by construction: an unset INGEST_REPLAY_R2_ENDPOINT keeps today's inline path, which is also what self-hosted and BYO-ClickHouse deployments run, and a missing R2 binding makes API hydration a no-op. Rollback is unsetting one env var. SigV4 is hand-rolled (~100 lines) against the hmac/sha2/chrono/reqwest crates the binary already links, rather than pulling the smithy stack in for one verb; it is pinned to AWS's published PUT Object test vector. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019vF2QDhdj77aJrjm89fgiq
|
Your Pullfrog Router balance is exhausted. You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer. Top up balance → · Enable auto-reload →
|
|
CI red on TypeScript ( Traced to b84e828 "Increase graph sample count" on Verified by checking out b84e828 directly, with none of this branch's code: On this branch alone that file passes 17/17, and nothing here touches the ladder — the only web change in this PR is a comment. Leaving it to whoever owns b84e828, since the stale expectations should move to whatever the new sample count intends rather than to whatever makes them green. Happy to take it if that's preferred. I'll merge Nothing else is red: the pullfrog check is a Router balance/billing failure, unrelated to the diff. Generated by Claude Code |
|
Your Pullfrog Router balance is exhausted. You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer. Top up balance → · Enable auto-reload →
|
The lifecycle rule was pinned to prefix "v1/", but replay_object_key versions its scheme on purpose so a future format change can write under a new prefix while the old one ages out. The first such change would have silently stopped expiring anything, and the bucket would grow forever with no failing test to catch it. The bucket is single-purpose, so the rule covers all of it.
|
Your Pullfrog Router balance is exhausted. You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer. Top up balance → · Enable auto-reload →
|
|
Pushed one commit here ( The lifecycle rule was pinned to The bucket is single-purpose (only the ingest gateway writes it, only the Everything else in this PR reviewed clean from #338's side. |
🍁 Maple PR previewNote Preview resources were removed when this pull request closed. Final commit |

Why
session_replay_eventsheld the raw rrweb payload as an opaqueEventsString. Nothing ever queried inside it — the only reader (sessionReplayEventsQuery) selects the whole column for one(OrgId, SessionId)and orders byChunkSeq— so it was a key/value blob fetch paying warehouse storage prices, and it's the bulk of a session's bytes (the per-session budget is sized against a ~594 MB p99).The sibling
session_eventstable deliberately stays in ClickHouse. It's queried across sessions — the event-match semi-join ("sessions where a 5xx happened") and thelagInFrameactive/idle window function — which is what a column store is for. Only the blobs move.What changed
The ingest gateway PUTs the browser's gzip verbatim to an S3-compatible bucket and enqueues a thin index row with an empty
Events; the API refills the payload on read. The HTTP response shape is unchanged for both v1 and v2.No schema change
An empty
Eventsis already an unambiguous "blob-backed" marker:flush()returns early on an empty buffer, so the SDK never uploads a chunk with no events. The object key derives from(OrgId, SessionId, ChunkSeq), all of which the reader already has.Adding a
StorageKeycolumn would have cost a migration across three schema systems plus an org-visible regression — aSCHEMA_VERSIONbump gatesclickhouse_ready, so every not-yet-migrated BYO-ClickHouse org would have had all signals (logs, traces, metrics) routed to Tinybird until they ran the schema-apply workflow, plus the first-ever versioned local-schema migration edge for a table local mode never reads. Verified this PR leavesSCHEMA_VERSIONat 12 and the local structural manifest at v1; only the Tinybird-coupledPROJECT_REVISIONmoves, from the datasource description edits.Invariants
ByteSizestays uncompressed. It's a published v2 field and the input toReplaySessionBudget's 1 GiB ceiling, both denominated in decompressed bytes.read_to_stringis replaced with a streaming decode-and-discard counter — same number, same rejection of malformed gzip, without the multi-megabyteStringalloc + JSON escape + WAL push that was the actual cost.INGEST_REPLAY_R2_ENDPOINTkeeps today's inline path (what self-hosted and BYO-ClickHouse run); a missing R2 binding makes API hydration a no-op. Rollback is unsetting one env var — no schema state to unwind.Notable choices
hmac/sha2/chrono/reqwestcrates the binary already links, rather than pulling the smithy stack in for one verb. Pinned to AWS's published PUT Object test vector.ExportDestinationlane. TheExportWorkerbatches 5000 rows / 4 MiB into one NDJSON body; a blob PUT is one object, one request — a lane would be degenerate and would still push megabytes through the WAL, which is the cost being removed.paginateArraystill slices post-materialization from the warehouse — that part is unchanged.)Rollout
Ships dark:
INGEST_REPLAY_R2_*is unset, so ingest behaves exactly as today until the Railway env vars are set. Watchmaple.replay.storage(inline→r2) and the newingest_replay_blob_put_failed_total, which should stay at zero.Follow-ups, deliberately not in this PR: delete the
events != ""dual-read branch at T+31 days; per-chunk URLs and progressive loading. Note the storage move does not fix "materialize the whole session in the Worker" — that's already the behavior today (sessionReplayEventsQueryhas no LIMIT) and is what the progressive-loading follow-up addresses.Cache-Controlon the events response was scoped in but left out: in this framework it needs header declarations on the versioned v1 and v2 endpoint schemas, which isn't the cheap change it looked like.Testing
cargo test(ingest): 70 pass. New coverage for the key scheme, SigV4 against the AWS vector, the byte counter vsread_to_string, verbatim-gzip storage, the inline fallback, and the orphan-prevention invariant (R2 5xx → non-2xx and zero WAL growth).apps/apiroutes + platform: 24 files / 198 tests pass, including 8 newReplayBlobStoretests. The key-scheme expectations are pinned in both the Rust and TS suites — nothing at runtime reconciles the writer and reader, and a divergence would read as "every recording is empty".@maple/query-engine(949),@maple/domain(448),@maple/web(1204) pass.bun typecheck36/36.telemetry.http.test.ts, which builds its own v2 layer stack. The route layers now provideReplayBlobStoreLivethemselves. Verified against the parent commit that the failure was mine, not pre-existing.@maple/clifails in this sandbox ("Failed to start server. Is port 0 in use?") — confirmed identical on a clean checkout of the parent commit.Reviewer notes
alchemy@2.0.0-beta.64does exposeCloudflare.R2.Bucketwith thelifecycleRulesshape used here (verified against installed sources), but the bucket + binding have not been deployed to any stage — that's the first operational step.apps/ingest/src/r2.rsdocuments the sixINGEST_REPLAY_R2_*vars.🤖 Generated with Claude Code
https://claude.ai/code/session_019vF2QDhdj77aJrjm89fgiq
Generated by Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.