Skip to content

feat(analytics): conversion and export event instrumentation - #173

Merged
obvious-autobuild-staging[bot] merged 2 commits into
masterfrom
feat/analytics-events
Sep 1, 2026
Merged

feat(analytics): conversion and export event instrumentation#173
obvious-autobuild-staging[bot] merged 2 commits into
masterfrom
feat/analytics-events

Conversation

@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor

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.ts extends the analytics helper with trackEvent(name, props), fanning each event from a single call site to both surfaces: window.plausible under its capitalized goal name with { props }, and gtag as a lowercase machine-named event — reaching Google Ads today and GA4 automatically once VITE_GA4_MEASUREMENT_ID is configured.

  • conversiondirection (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.
  • exportvia (copy|download) + resolved format, on every intentional copy/download click, no debounce.
  • Ads seam (one line): no send_to yet; the conversion action's label lands in one line once David creates the action in the Google Ads console.
  • FAQ privacy clause now mentions the first-party conversion cookie — adopting the advisory deferred from the fix(analytics): stop Plausible double-counting; distinct permalink event; honest privacy copy #172 review.
  • docs/verification-report.md gains an "Analytics events" subsection: names, props, fire points, dashboard expectations.

Strictly additive: #172's single-mount-pageview and permalink_view semantics 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 + stateful createConversionTracker (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.tsxonFile now carries the source (picker | drop) so the event can distinguish file vs drag.
  • Intentionally excluded: no send_to on 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

  • 12 new unit tests in 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.
  • App-level integration: exactly one conversion after settle; export payload on output copy click.
  • Full suite 123/123 passing; lint + tsc -b clean; dist rebuilt and committed fresh; verify-seo.sh passing (21360 bytes prerendered).

🔗 Obvious Project · 🧵 Obvious Thread

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.
@obvious-autobuild-staging
obvious-autobuild-staging Bot marked this pull request as ready for review September 1, 2026 17:27

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 reset lastSignature). This contradicts the stated "refires on new paste/upload/drop" semantics and undercounts the conversion metric.
  • app/src/App.tsx:84 — The fire-time isValid re-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. Also byteLength(obs.text) is computed twice per fire.

Suggestions

  • app/src/App.tsx:157 — Permalink discrete fires 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.


View full review in Obvious

Comment thread app/src/analytics/events.ts Outdated
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)}`;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread app/src/App.tsx Outdated
settleMs: CONVERSION_SETTLE_MS,
minWindowMs: CONVERSION_MIN_WINDOW_MS,
isValid: (direction, text) =>
convertText(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 637f6a5isValid 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.
@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor Author

Replying to this comment

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 cancel() resets the last signature (clear + re-paste counts again), each with a regression test; settle-path validity reads the memoized result via a ref instead of re-running the full conversion, with discrete fires converting directly and the permalink hydration refreshing the ref pre-fire (which also absorbs the suggestion about pre-hydration options). Full suite 125/125, lint + tsc clean, dist rebuilt and committed fresh, verify-seo.sh passing.

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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() resets lastSignature so paste→clear→paste re-counts. Both scenarios carry new regression tests.
  • Medium (duplicate conversion)isValid now trusts the memoized result.ok when 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. byteLength computed 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.


View full review in Obvious

@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor Author

Replying to this comment

Thank you for the verification — all three items resolved as described, no further changes needed. Ready for merge.

@obvious-autobuild-staging
obvious-autobuild-staging Bot merged commit 655c554 into master Sep 1, 2026
3 checks passed
@obvious-autobuild-staging
obvious-autobuild-staging Bot deleted the feat/analytics-events branch September 1, 2026 18:08
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.

1 participant