Skip to content

feat(cli): align primitive funnel measurement - #3052

Merged
miga-heygen merged 3 commits into
feat/thread-message-stackfrom
feat/video-primitives-measurement-parity
Sep 4, 2026
Merged

miga-heygen merged 3 commits into
feat/thread-message-stackfrom
feat/video-primitives-measurement-parity

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Aug 5, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  • Duplicate terminals. Two concurrent renders of the same project each emit a terminal success, so the funnel appears to convert twice.
  • Split funnels. The OAuth browser round-trip restarts the process, so the post-auth half of the journey gets a fresh id and the funnel silently breaks in the middle.
  • Unbounded properties. Unclamped counts and durations blow up property cardinality and let NaN reach 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:

  • A claimed terminal event was lost permanently. The claim is taken before the send, but delivery was the fire-and-forget exit flush: a detached child, no retry, no way to report failure. One real install claimed primitive_preview_succeeded and it never arrived. Because the claim is durable and the delivery was not, that step could never be emitted again.
  • Step order was not recoverable. primitive_auth_completed and primitive_install_started are 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

  • One table owns the vocabulary. PRIMITIVE_FUNNEL_SIDE_EFFECTS in telemetry/primitive-funnel.ts maps 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.
  • Order is data, not a timestamp artifact. Each entry carries a step, emitted as funnel_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.
  • Terminals are claimed, not counted. Preview and render success/failure take an exclusive claim by creating a marker file with 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.
  • A claim is only spent once the event is delivered. flush() now reports whether PostHog acknowledged the batch, the terminal path awaits it, and releasePrimitiveFunnelEvent hands the claim back when it did not. This is deliberately at-least-once: every event carries a stable event_id, so a duplicate collapses downstream, while a loss is unrecoverable. Claims are also no longer spent while telemetry is opted out.
  • Properties are clamped at the emit site. result_count is bounded to 0 to 1000, result_rank to 1 to 1000, duration_ms to 0 to 86,400,000. Non-finite input collapses to the nearest bound instead of leaking NaN.
  • Funnel context survives the process gap. primitive-funnel-state.ts persists the context through temp-file plus rename with 0600 / 0700 modes, 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.
  • Identity binds once, and only after auth. The funnel identifies at most once, only on auth completion, and only when an account id is present. Discovery and result selection always report auth_state: anonymous.
  • All three catalog output modes report delivery. JSON, table, and interactive paths emit result delivery with no session required, and a direct add carries 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_step ordering 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_step puts 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 add cannot produce steps 1 and 2. Funnel state is only persisted at install, so a preceding catalog search 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.

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 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. createClaimMarker uses openSync(path, "wx", 0o600) (primitive-funnel-state.ts:38), which is O_CREAT|O_EXCL - so the filesystem arbitrates and exactly one process wins. The emittedEventIds pre-check at :126 runs before it and is only a fast path; two processes that both pass it still resolve correctly at the marker. The comments at :133-134 and :168-169 name 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:110 returns true for 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 :107 and :126-127 also means events enqueued mid-flight are not swept into the delivered set.
  • performance.now() rather than Date.now() for durations - monotonic, so a clock adjustment mid-render cannot produce a negative or wild interval.
  • The async handleOutput conversion (preview.ts:934) latches detected before the await and says so in the comment. That is the exact re-entrancy question the change raises, answered correctly.
  • funnel_step numbering matches the description: failure steps share their success step's number (preview 7/7, render 8/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-760 calls markRenderSucceeded() 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-932 and at preview.ts:1142.
  • Failure. render.ts:745-750 awaits trackPrimitiveRenderFailed before handleRenderError, 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:110 returns true before the fetch. This was the failure I most expected to find, since flush() returning false for "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-25 names precisely why that ordering matters.
  • The state file write is atomic and mode-correct: temp file at 0600, directory at 0700, renameSync into place (primitive-funnel-state.ts:87-96).
  • claimPrimitiveFunnelEvent returning false conflates "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 miga-heygen 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.

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

@miga-heygen
miga-heygen merged commit 52a7955 into feat/thread-message-stack Sep 4, 2026
18 checks passed
@miga-heygen
miga-heygen deleted the feat/video-primitives-measurement-parity branch September 4, 2026 18:06
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.

3 participants