Repository navigation
feat(cli): align primitive funnel measurement - #3052
miga-heygen merged 3 commits into
Conversation
Terminal funnel events took a durable single-use claim before they were sent, but delivery was the fire-and-forget exit flush: a detached child with no retry and no way to report failure. An unacknowledged send left the claim spent on an event PostHog never received, so that step could never be emitted again. Observed in a real install, where primitive_preview_succeeded was claimed and never arrived. The terminal path now awaits delivery and hands the claim back when the batch is not acknowledged. That trades a permanent loss for a possible duplicate, which is the cheaper failure: every funnel event carries a stable event_id, so duplicates collapse downstream while a loss is unrecoverable. Claims are also no longer spent while telemetry is opted out. Separately, funnel order is now carried by an explicit funnel_step rather than inferred from timestamps. Auth completion and install start are emitted back to back inside one command and land in the same millisecond, so ordering by time resolved them arbitrarily and could report an install beginning before the auth that authorized it.
jrusso1020
left a comment
There was a problem hiding this comment.
Reviewed at head 9fcf1d07ec7bb14c63f6b54b71d0f9e4b5621a86.
Audited (read end-to-end): telemetry/primitive-funnel.ts, telemetry/primitive-funnel-state.ts, telemetry/primitive-funnel-command.ts, telemetry/transport.ts, and the preview.ts / render.ts call-site changes.
Trusting (not read): commands/add.ts, commands/catalog.ts, cli.ts, registry/threadMessageStackAuthorization.ts, and the five test files. So the OAuth-continuity and catalog-delivery claims in the description are not independently checked here.
Stack note: the base is feat/thread-message-stack, and #3046 is currently conflicted, so the diff that eventually lands is not this diff. Everything below is against the PR's own base.
Strengths
- The claim is a real mutual exclusion, not an advisory flag.
createClaimMarkerusesopenSync(path, "wx", 0o600)(primitive-funnel-state.ts:38), which isO_CREAT|O_EXCL- so the filesystem arbitrates and exactly one process wins. TheemittedEventIdspre-check at:126runs before it and is only a fast path; two processes that both pass it still resolve correctly at the marker. The comments at:133-134and:168-169name which of the two is authoritative, in both directions. flush()'s new boolean is contract-correct in the two cases that decide whether a claim gets burned.transport.ts:110returnstruefor an empty queue - "nothing queued is not a delivery failure" - which is the case that would otherwise release a legitimate claim and re-emit it on the next command. The snapshot-then-filter at:107and:126-127also means events enqueued mid-flight are not swept into the delivered set.performance.now()rather thanDate.now()for durations - monotonic, so a clock adjustment mid-render cannot produce a negative or wild interval.- The
async handleOutputconversion (preview.ts:934) latchesdetectedbefore the await and says so in the comment. That is the exact re-entrancy question the change raises, answered correctly. funnel_stepnumbering matches the description: failure steps share their success step's number (preview7/7,render8/8), so ordering by step is stable and no number is doubled up.
important - "no product behavior changes" is not accurate: the terminal awaits now sit in front of the user's own output
flush() is bounded by FLUSH_TIMEOUT_MS = 5_000 (transport.ts:10), and the terminals are now awaited on paths that previously fired and forgot. Two shapes, and the second is the one that matters:
- Success.
render.ts:759-760callsmarkRenderSucceeded()and then awaits the flush, so an offline or proxied user watches a finished render sit for up to five silent seconds before the command returns. Same at:931-932and atpreview.ts:1142. - Failure.
render.ts:745-750awaitstrackPrimitiveRenderFailedbeforehandleRenderError, so the user's error message is delayed by the same budget. Identically at:909,preview.ts:403, and:1103. Whatever the argument for blocking a success path on telemetry, blocking the diagnostic on it is hard to defend - the person is already waiting to find out what went wrong.
The fix is ordering, not removal: print first, emit the terminal after. Where that is awkward, a much shorter timeout for the terminal flush specifically would bound the damage.
Related, at preview.ts:949: child.on("error", async (err) => ...) hands an async function to an EventEmitter, so the returned promise has no handler and a rejection surfaces as an unhandled rejection. The spinner and the error print are also inside it, after the await, so a failed studio start reports late for the same reason.
important - boundedInteger sends NaN to the maximum, which is the worst of the available answers
primitive-funnel.ts:81-84:
if (!Number.isFinite(value)) return value < 0 ? minimum : maximum;NaN < 0 is false, so NaN returns maximum. Measured:
duration_ms <- NaN => 86400000 (24 hours)
duration_ms <- +Infinity => 86400000
duration_ms <- -Infinity => 0
result_count <- NaN => 1000
result_rank <- NaN => 1000
The infinities are right. NaN is not: the description says non-finite input "collapses to the nearest bound", and NaN has no nearest bound. Picking the max is the worst option available, because NaN is conspicuous downstream and gets filtered, whereas 86,400,000 is a plausible number that silently moves every duration percentile - in a PR whose whole purpose is that a downstream read of this funnel can be trusted.
This is currently unreachable from the four call sites - every durationMs is performance.now() - performance.now(), and the counts come from array lengths - so it is a latent defect rather than a live one. But this clamp is the designated guard for exactly that input, and it fails in the permissive direction. For a duration, dropping the property is the only honest answer (0 is also a lie, and it drags the median down); Number.isNaN(value) needs its own branch either way.
important - the crash window between claim and acknowledgement is still an unrecoverable loss, of the same shape as the bug being fixed
primitive-funnel-command.ts:30-32 claims, emits, awaits, and releases on a falsy flush. If the process dies between the claim at :30 and the release decision at :32 - Ctrl+C on a long render, an OOM kill, a CI timeout - the marker persists and no later command can ever emit that step. That is the failure the description opens with ("the claim is durable and the delivery was not"), narrowed from always-on-exit-flush to only-on-crash, but not closed.
The two halves of the reasoning at primitive-funnel-state.ts:145-151 are also in tension. :145-146 says the claim must precede the send "because its whole job is to stop two concurrent processes both emitting one terminal event", while :150-151 says duplicates are the cheap failure because event_id collapses them downstream. If the second sentence is true - and releasePrimitiveFunnelEvent exists on the premise that it is - then pre-send exclusivity is buying protection against something the design already tolerates, and paying for it with a crash-shaped permanent loss. Writing the marker after the acknowledgement makes it a pure dedupe record: concurrent processes may both send, event_id collapses them, and a crash costs nothing that cannot be retried. Worth saying which property you actually want, since the code currently claims both.
nit - duration_ms is measured from four different origins in one command
preview.ts starts a primitiveCommandStartedAt at :214 (inside run), and then again at :964 (runDevMode), :1018 (runLocalStudioMode) and :1058 (runEmbeddedMode). So the same primitive_preview_succeeded event reports time-since-command-start on some paths and time-since-mode-entry on others, and the mode functions are entered after argument resolution, project discovery and version checks. The number is comparable within a path and not across them, which is the sort of thing that only shows up as an unexplainable bimodal distribution months later. Threading the run() value through, as attachStudioReadyHandler already does for its own parameter, makes it one interval.
nit - claim markers are never pruned
releasePrimitiveFunnelEvent removes a single marker, and nothing else touches .hyperframes/primitive-funnel-claims/. Since eventId is ${installId}:${suffix}, every install leaves two permanent files in the user's project directory. Tiny individually, unbounded over a project's life, and there is no reaper. Worth knowing: this repo's own .gitignore carries .hyperframes/ with a comment recording that it had to be broadened once after preview-generated state got committed - I did not find an equivalent ignore in the project template, so a generated project may well commit these.
Checked, not a finding
- An empty queue does not release a claim -
transport.ts:110returnstruebefore the fetch. This was the failure I most expected to find, sinceflush()returningfalsefor "nothing to send" would release every legitimate claim and re-emit on the next command. - A non-OK response drops the batch from the queue and returns
false, so the claim is released and the terminal is re-emittable by a later command. Consistent, though the justifying comment at:124-125("PostHog will reject the retry the same way") holds for a 400 and not for a 429 or a 502, where the other events in that batch are dropped with no claim to give back. Pre-existing drop behaviour; only the return value is new. shouldTrack()is checked before the claim (primitive-funnel-command.ts:26), so an opted-out run cannot burn a claim - the comment at:24-25names precisely why that ordering matters.- The state file write is atomic and mode-correct: temp file at
0600, directory at0700,renameSyncinto place (primitive-funnel-state.ts:87-96). claimPrimitiveFunnelEventreturningfalseconflates "someone else holds it" with "the state file is unreadable", and the caller silently returns either way. That is the right call for telemetry that must never break the command, so I am noting it rather than filing it.
Verdict: APPROVE
Reasoning: This is careful work - the exclusion is real, the flush contract is right in both edge cases that matter, and the comments consistently name the trade-off rather than hiding it. Three things are worth fixing: the awaited terminals now delay the user's own error output, the NaN clamp resolves in the permissive direction on the one input it exists to catch, and the claim still has a crash-shaped permanent-loss window that the design's own duplicate tolerance would let you close. None of them is a defect in the instrumentation the PR set out to build.
— Rames Jusso
miga-heygen
left a comment
There was a problem hiding this comment.
Reviewed at exact head. Extends the primitive funnel measurement: transport batching, funnel context persistence across preview/render, event alignment between add/catalog flows. Tests cover the full funnel lifecycle — auth→install→preview→render. The transport test verifies batch delivery and the command-level test verifies context propagation through preview and render.
— Miga
What
Makes the primitive install funnel introduced in #3046 actually measurable: one canonical ordered event vocabulary, bounded event properties, anonymous result delivery across every catalog output mode, funnel continuity across the OAuth process boundary, and process-atomic terminal claims for preview and render.
Instrumentation correctness only. No product behavior changes.
Why
The first slice emitted events, but it did not guarantee they were the same events, in the same order, carrying the same funnel id, once the flow crossed a process boundary or ran concurrently. That leaves three failure modes that make any downstream read of the journey untrustworthy:
NaNreach the transport.Two further failures were found by running the built CLI against the real ingest endpoint rather than against mocks, and are fixed here:
primitive_preview_succeededand it never arrived. Because the claim is durable and the delivery was not, that step could never be emitted again.primitive_auth_completedandprimitive_install_startedare emitted back to back inside one command and land in the same millisecond. Two identical warehouse queries returned them in opposite orders, so a funnel keyed on the sequence would intermittently see the install beginning before the auth that authorized it.How
PRIMITIVE_FUNNEL_SIDE_EFFECTSintelemetry/primitive-funnel.tsmaps each lifecycle step to its event name, its stable event-id suffix, and its position in the canonical order. Every emitter goes through it, so a rename cannot drift between call sites.step, emitted asfunnel_step. Consumers order by it instead of by a millisecond that ties. Failure steps share the number of the step they terminate; shipped numbers are never reused.O_EXCL(wx), named by the sha256 of the event id, under.hyperframes/primitive-funnel-claims/. First writer wins, everyone else is a no-op. Repeated 32-process preview and render races produced exactly one claimant each time.flush()now reports whether PostHog acknowledged the batch, the terminal path awaits it, andreleasePrimitiveFunnelEventhands the claim back when it did not. This is deliberately at-least-once: every event carries a stableevent_id, so a duplicate collapses downstream, while a loss is unrecoverable. Claims are also no longer spent while telemetry is opted out.result_countis bounded to 0 to 1000,result_rankto 1 to 1000,duration_msto 0 to 86,400,000. Non-finite input collapses to the nearest bound instead of leakingNaN.primitive-funnel-state.tspersists the context through temp-file plus rename with0600/0700modes, and re-validates every field on read. A malformed state file degrades to "no funnel" rather than throwing, so telemetry can never break the command it instruments.auth_state: anonymous.addcarries the same funnel id through OAuth, install, preview, and render.Test plan
Full CLI suite on Linux: 187 files, 2,508 passed, 2 skipped, 0 failed.
Focused telemetry, funnel, auth, resume, and catalog suites: 19 files, 237 passed.
Concurrency: repeated 32-process preview and render races, exactly one terminal claimant per race.
New coverage: claim released on unacknowledged delivery and re-emitted by a later command, claim retained on acknowledgement, no claim spent while opted out, and a
funnel_stepordering assertion over the delivered batches.End-to-end against real ingest, three separate installs of the built CLI: anonymous discovery with no session, add with caller-supplied messages, background preview, and a real 8-frame 1920x1080 MP4. Confirmed in the warehouse that the terminal preview event now arrives, and that ordering by
funnel_stepputs auth completion before install start even though both events still share one millisecond.Typecheck,
oxfmt --check,oxlint: clean.Unit tests added/updated
Manual testing performed (three live end-to-end installs of the built CLI, verified against real ingested events)
Documentation updated (if applicable) - no user-facing surface changed
Known limitation
A direct
hyperframes addcannot produce steps 1 and 2. Funnel state is only persisted at install, so a precedingcatalogsearch carries its own funnel id and does not stitch to the add that follows. Only the interactive catalog path produces the full ordered journey. That is a scoping question about what the funnel should mean, not a defect, so it is deliberately unchanged here.Stack
Based on
feat/thread-message-stack(#3046); merge that first. This PR proves instrumentation correctness only. It does not claim production adoption, which stays gated on merging and releasing the stack and then a separately authorized read of real journey data.