feat(analytics): conversion and export event instrumentation - #173
Conversation
Extend the analytics helper with trackEvent(name, props), fanning each
event from one call site to both surfaces: window.plausible under its
capitalized goal name with { props }, and gtag as a lowercase
machine-named event (Google Ads today; GA4 automatically once
VITE_GA4_MEASUREMENT_ID is configured).
- conversion: direction + input method (paste/file/drag/permalink) +
input byte bucket. Fires once per first stable output after the input
settles ~2s (only on a valid result), keyed by a direction+method+length
signature; refires only when the input actually changes; capped at one
event per 2s window. Picker/drop/permalink successes fire immediately
under the same gates.
- export: via (copy|download) + resolved format, on every intentional
copy/download click, no debounce.
- Ads seam documented: conversion events carry no send_to yet; the
conversion action's label lands in one line once David creates the
action in the Ads console.
- FAQ privacy clause now mentions the first-party conversion cookie
(adopting the deferred advisory from the #172 review).
- docs/verification-report.md: "Analytics events" subsection covering
event names, props, fire points, and dashboard expectations
(Plausible goals conversion/export for David to add; GA4 receives the
same events once VITE_GA4_MEASUREMENT_ID is set).
- Tests: 12 new unit tests (debounce, fire-once-per-signature, 2s window,
exact gtag/Plausible payloads, no-op safety) plus App-level integration
for settle semantics and export clicks. Full suite: 123 passing.
- dist rebuilt and committed fresh (dist-freshness gate); verify-seo.sh
passing.
There was a problem hiding this comment.
Obvious Code Review
Verdict: COMMENT (0 Blocker · 0 High · 2 Medium · 1 Suggestion)
Reviewed the canonical patch at c1f5348. Independently verified all claims at the head: 123/123 tests passing (14 files, isolated worktree run), tsc -b clean, SEO gate passes at exactly 21360 prerendered bytes, and the committed dist bundle is in sync with the source. #171/#172 pageview semantics are untouched, and the FAQ now discloses the first-party conversion cookie.
Medium
app/src/analytics/events.ts:118— Conversion signature is direction+method+byte-length only, so genuinely new inputs of the same size never re-fire (and a paste→clear→paste of the same content is never re-counted —cancel()doesn't resetlastSignature). This contradicts the stated "refires on new paste/upload/drop" semantics and undercounts the conversion metric.app/src/App.tsx:84— The fire-timeisValidre-runs the full conversion (parse + output serialization) on the entire settled input, duplicating the memoized conversion at App.tsx:122 — the app's heaviest operation runs twice on the main thread per settled input, including at the 2MB large-input threshold. AlsobyteLength(obs.text)is computed twice per fire.
Suggestions
app/src/App.tsx:157— Permalinkdiscretefires before hydration state lands, so the immediate validity check uses pre-hydration options; correctness relies on the 2s settle retry healing it.
Full evidence and suggested fixes in the review artifact. Readiness: CLEAN — no blocking findings.
| let lastFiredAt = 0; // epoch 0 = "long ago": the first fire is never window-blocked | ||
|
|
||
| function signatureOf(obs: ConversionObservation): string { | ||
| return `${obs.direction}:${obs.input}:${byteLength(obs.text)}`; |
There was a problem hiding this comment.
Medium · reliability — The conversion signature is ${direction}:${input}:${byteLength} only, so attemptFire suppresses any later observation with the same signature. Two real undercount sequences: (1) a user converts two different inputs of the same byte length in the same direction+method (e.g. two different ~5KB CSV drops) — the second is deduped away forever, contradicting the PR's stated "refires on new paste/upload/drop" semantics; (2) paste → Clear → paste the same content — cancel() clears pending but never resets lastSignature, so the repeat conversion is never counted. The "fires once per signature" test enshrines this, so it won't surface later. Since this is the conversion metric the launch dashboard depends on, worth fixing: fold a cheap content fingerprint (FNV-1a/djb2 over the text — O(n) once per fire attempt, trivial next to the isValid conversion) into the signature, and reset lastSignature in cancel().
There was a problem hiding this comment.
Fixed in 637f6a5 — two changes: (1) the signature now folds an FNV-1a content fingerprint (cheap O(n) pass, base36) over the text into direction:input:bytes:fingerprint, so two different same-length inputs count as separate conversions, and (2) cancel() now resets lastSignature, so paste → clear → paste of identical content fires again. Both undercount sequences have regression tests (counts genuinely new inputs of the same byte length, counts a repeat conversion after clear + re-paste of identical content). The existing fire-once-per-signature test still passes since identical text produces an identical fingerprint — the test no longer enshrines the undercount.
| settleMs: CONVERSION_SETTLE_MS, | ||
| minWindowMs: CONVERSION_MIN_WINDOW_MS, | ||
| isValid: (direction, text) => | ||
| convertText( |
There was a problem hiding this comment.
Medium · performance — The fire-time validity predicate re-runs convertText on the full settled input, which does the complete parse plus full output serialization (JSON.stringify / CSV build) — exactly the work the memoized result at App.tsx:122 already did on the debounced input. Every settled input now pays the app's heaviest main-thread operation twice; for inputs near the 2MB "converting on pause" threshold that doubles a multi-hundred-ms stall with no large-input consideration. Also attemptFire encodes the full text twice (new TextEncoder().encode at events.ts:123 for the signature and :133 for the size bucket). Suggested: have isValid consult the already-computed memoized result via a ref (matching the settled text), or use a parse-only validity check that skips output serialization; compute byteLength once per fire.
There was a problem hiding this comment.
Fixed in 637f6a5 — isValid now consults the already-computed memoized result via converterStateRef (which carries input + resultOk): settle fires land ≥2s after the last edit, by which time the memoized result corresponds exactly to the settled text, so the fire-time check is free and the full parse+serialize is no longer paid twice. Discrete fires (upload/drop/permalink) can land before React has rendered the new input, so those still convert directly — and the permalink hydration effect now refreshes converterStateRef with the hydrated direction/options/result before its immediate fire, which also absorbs the suggestion about pre-hydration options (the check no longer relies on the 2s settle retry). byteLength is computed once per fire in attemptFire.
…dity Address the two Medium findings from the Obvious review: - Fold an FNV-1a content fingerprint into the conversion signature so genuinely new inputs of the same byte length count, and cancel() forgets the last signature so clear + re-paste of identical content is counted again. Both undercounts previously suppressed real conversions on the launch dashboard. - Settle-path validity now reads the memoized result via converterStateRef instead of re-running the full conversion; the full parse+serialize is no longer paid twice on the main thread. Discrete fires (upload/drop/permalink) still convert directly when React has not rendered the new input, and the permalink hydration refreshes the ref with hydrated direction/options before its immediate fire. Adds regression tests for same-length refire and clear-then-re-paste.
|
Thanks for the review. Both Medium findings are fixed in 637f6a5: the conversion signature now includes an FNV-1a content fingerprint (same-length, different content inputs both count) and |
There was a problem hiding this comment.
Obvious Code Review — Pass 2 (incremental)
No findings — clean pass.
Reviewed the fix commit 637f6a5 (fingerprint conversion signature; reuse memoized validity) against the canonical patch at the new head.
All three Pass 1 findings are resolved and verified:
- Medium (signature undercount) — FNV-1a content fingerprint now distinguishes same-length inputs, and
cancel()resetslastSignatureso paste→clear→paste re-counts. Both scenarios carry new regression tests. - Medium (duplicate conversion) —
isValidnow trusts the memoizedresult.okwhen the settled text matches (settle fires land ≥2s after the last edit, past the 150ms/1000ms debounce, so the memo always corresponds to the settled text), with a correctly scoped direct-conversion fallback for discrete fires.byteLengthcomputed once per fire. - Suggestion (permalink ordering) — the hydration effect now seeds the converter-state ref with hydrated direction/options/input/resultOk before
tracker.discrete, so the immediate validity check no longer relies on the 2s settle retry.
Independently verified at head 637f6a5: 125/125 tests passing (14 files, including the 2 new regression tests), tsc -b clean, SEO gate passing (21360 bytes), and the rebuilt dist bundle is in sync with the source.
Readiness: CLEAN — merge confidence 5/5.
|
Thank you for the verification — all three items resolved as described, no further changes needed. Ready for merge. |
Why
The analytics restoration (#171, follow-up #172) ships pageviews but David wants conversion and export signals for the launch dashboard. The app converts live on every keystroke, so counting raw conversions would be noise — counting needs settle-debounce and signature semantics, not just an event on every parse.
What
app/src/analytics/events.tsextends the analytics helper withtrackEvent(name, props), fanning each event from a single call site to both surfaces:window.plausibleunder its capitalized goal name with{ props }, and gtag as a lowercase machine-named event — reaching Google Ads today and GA4 automatically onceVITE_GA4_MEASUREMENT_IDis configured.conversion—direction(csv_to_json/json_to_csv),input(paste/file/drag/permalink),size(byte bucket). Fires once per first stable output after the input settles ~2s and only on a valid result, keyed by a direction+method+length signature; refires only when the input actually changes (new paste/upload/drop or permalink hydration); never more than one per 2s window. Discrete successes (picker, drop, permalink hydration) fire immediately, same gates.export—via(copy|download) + resolvedformat, on every intentional copy/download click, no debounce.send_toyet; the conversion action's label lands in one line once David creates the action in the Google Ads console.docs/verification-report.mdgains an "Analytics events" subsection: names, props, fire points, dashboard expectations.Strictly additive: #172's single-mount-pageview and
permalink_viewsemantics are untouched — Plausible stays in manual mode, which is exactly why this PR follows #172.How to Review
app/src/analytics/events.ts— fan-out + statefulcreateConversionTracker(settle debounce, signature dedupe, 2s window, fire-time validity predicate).app/src/App.tsx— wiring: input methods (typing/paste → settle; picker/drop/permalink → immediate), copy/download handlers.app/src/components/InputPane.tsx—onFilenow carries the source (picker|drop) so the event can distinguish file vs drag.send_toon conversion events (one-line seam), no event-name splitting per method (Plausible custom-property breakdown may be plan-gated; fallback noted in the verification report, not preemptively split).Test Evidence
app/src/analytics/events.test.ts(fake timers): debounce, burst-of-keystrokes, fire-once-per-signature, 2s window cap, exact gtag/Plausible payloads, empty/invalid never counted, cancel-on-clear, no-op safety.tsc -bclean; dist rebuilt and committed fresh;verify-seo.shpassing (21360 bytes prerendered).🔗 Obvious Project · 🧵 Obvious Thread