Skip to content

PostHog: typed analytics module, and Execile → Execil rename - #35

Merged
Abuubkar merged 17 commits into
mainfrom
dev
Sep 7, 2026
Merged

Abuubkar merged 17 commits into
mainfrom
dev

Conversation

@Abuubkar

@Abuubkar Abuubkar commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Two commits: the wizard's PostHog install (a96707e), then a rework that puts the integration behind one module (5e10f5f).

What changed

src/analytics/ owns PostHog. events.ts declares a closed AnalyticsEvent union, useTrack.ts is the only caller of usePostHog(), and AnalyticsProvider.tsx is the only file that imports posthog-js. posthog-js imports are a lint error everywhere else, so this is enforced by the build rather than by review.

The five page-engagement events lost during the rewrite are back — cta_clicked, nav_clicked, phone_clicked, faq_opened, form_started — wired through the existing onActivate/onOpen seams, so base/ still has no analytics dependency.

The bug worth knowing about

deploy.yml passed VITE_POSTHOG_PROJECT_TOKEN while the code reads VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, and never passed the host. Production would have built with no token and reported nothing, silently. Fixed here; both repo variables now exist under the new names and the old one is deleted.

Verified

  • pnpm check green — 76 files formatted, 63 clean of lint and type errors
  • pnpm build succeeds, prerenders /, /privacy, /terms, /hipaa
  • All nine events confirmed firing in the browser; events confirmed landing in PostHog
  • Both guards tested by deliberately breaking them: a posthog-js import in a section fails lint, an invalid failure reason fails typecheck
  • window.posthog (dev only) confirmed absent from the production bundle

Known divergence, accepted

The integration runs on wizard defaults: autocapture, session replay, heatmaps and dead clicks are on. cookieless_mode: 'always' is set, so no cookies and no persistent distinct_id — but DOM selectors, element text and replay frames still flow.

/privacy and /hipaa, and issue #13's decision to ship without a consent banner, describe a quieter integration than the one running. This is a deliberate choice, not an oversight, and it is recorded in docs/provisioning.md step 7. It should be settled before counsel reviews the legal pages — see the legal-review section of #34.

Follow-ups not in this PR

  • AnalyticsProvider throws in dev when either variable is missing, so a fresh clone will not boot the dev server without them. Conflicts with the project's "runs with no account" constraint; one-line fix.
  • Issue Analytics #31 is closed with acceptance criteria the code deliberately no longer meets.
  • cta_clicked, nav_clicked and phone_clicked each duplicate a $autocapture event recording the same click.

- Added PostHog integration for user identification and event tracking in the application.
- Created new documentation files for identifying users and integrating with TanStack Start.
- Updated environment variables for PostHog project token and host.
- Removed deprecated analytics event tracking code and replaced it with PostHog's capture methods.
- Updated package dependencies to use the latest version of posthog-js.
- Refactored components to utilize PostHog for analytics events instead of custom tracking.
The wizard's install called posthog.capture() with string literals directly in
components. Every call site could invent an event name and attach arbitrary
properties, which is the opposite of what issue #31 asks for.

src/analytics/ now owns the integration:

- events.ts declares AnalyticsEvent, a closed union. The assessment failure
  reason is derived from the endpoint's own error union, so an event cannot
  report a reason the API is incapable of returning.
- useTrack.ts is the only file that calls usePostHog().
- AnalyticsProvider.tsx is the only file that knows PostHog exists; __root
  renders it and imports nothing from posthog-js.

posthog-js imports are a lint error outside src/analytics/, so this holds at
build time rather than by review.

Restores the five page-engagement events dropped during the rewrite --
cta_clicked, nav_clicked, phone_clicked, faq_opened and form_started -- through
the existing onActivate/onOpen seams, so base/ keeps no analytics dependency.

Also:

- deploy.yml passed VITE_POSTHOG_PROJECT_TOKEN while the code reads
  VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, and never passed the host at all. A
  production build would have shipped without a token and reported nothing,
  with no failure anywhere. Both public variables are now passed.
- window.posthog is assigned in dev only. The npm build never sets it, so the
  documented posthog.capture(...) console snippet threw ReferenceError.
- .claude/skills/** is excluded from fmt and lint, on the same grounds as
  docs/**: vendor-authored primary sources, not code to be reformatted.
The brand copy said "Execile" while the legal entity, the domain and the
contact address all said Execil: EXECIL PVT LTD, execil.net,
contact@execil.net. The page was displaying a name its own privacy policy
contradicted, so this is a correction rather than a rebrand.

Covers all three kinds of occurrence:

- Identifiers: package.json and the wrangler Worker name. Renaming the Worker
  is free today because nothing is deployed yet -- after a Cloudflare deploy
  this would create a second Worker rather than rename one.
- User-facing copy: site name, page title, OG alt text, nav home label, the
  Why Us comparison table and the Results placeholder.
- Docs: CLAUDE.md, CONTEXT.md, the provisioning runbook, the research notes
  and the design canvas.

public/og.png renders the wordmark as pixels, so a text replace could not fix
it; regenerated with scripts/og-image.py.

The GitHub repository is renamed to Abuubkar/execil and the git remote now
points at the new URL. The local working directory keeps its old name.
@Abuubkar Abuubkar changed the title PostHog: route events through a single typed module PostHog: typed analytics module, and Execile → Execil rename Sep 7, 2026
@Abuubkar

Abuubkar commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Added a second, separate topic to this branch: the Execile → Execil rename (9d4c1ea).

The brand copy said "Execile" while legalName was EXECIL PVT LTD and the domain is execil.net — so the page contradicted its own privacy policy. Covers identifiers (package.json, wrangler Worker name), user-facing copy, and all docs. public/og.png was regenerated because it renders the wordmark as pixels.

The GitHub repo is now Abuubkar/execil. Renaming the Worker name in wrangler.jsonc is free right now because nothing is deployed yet; after Cloudflare provisioning it would create a second Worker instead.

Happy to split this into its own PR if you'd rather review the two topics separately.

execil-dev-verify ran a duplicate Vite dev server on port 3100. Two Miniflare
instances against the same project contend for the local SQLite state and
crash workerd with SQLITE_BUSY, so a second config is a trap rather than a
convenience.
@Abuubkar

Abuubkar commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

Two-axis review against main: Standards (repo conventions and code smells) and Spec (issue #31, issue #13's resolution, and this PR's own claims).

One note first. The PR grew a third commit while this review ran: 9d4c1ea "Rename Execile to Execil throughout". It touches 18 files, renames the Worker in wrangler.jsonc, regenerates the OG image, and states the GitHub repo was renamed. Neither axis below covers it, and the PR description does not mention it. It is unrelated to analytics and would be cleaner as its own PR.

Standards

Hard breaches of documented standards

  • "Works with no accounts" is broken. docs/provisioning.md and the header of .env.example promise a fresh clone runs with nothing set. .env.example now ships literal <ph_project_token> / <ph_client_api_host> placeholders, which are truthy, so the guard in AnalyticsProvider passes and PostHog initialises against the literal host string with debug: true. Blank them instead and the dev-only throw stops the dev server. The provider's own docstring still claims it "runs with no PostHog account".
  • Stale doc in a file the PR edits. docs/provisioning.md still cites assessment_failed{reason:"delivery"}. The event is now assessment_submission_failed.

Judgement calls on documented rules

  • Import direction. src/analytics/events.ts type-imports from routes/api/assessment, and sections import analytics, so sections reach routes through analytics. Type-only, and main already had the same hop from Assessment.tsx. Suggest moving AssessmentResult to a leaf module and adding analytics/ to the chain in CLAUDE.md.
  • Lint boundary has gaps. The no-restricted-imports override in vite.config.ts lists routes, sections and base only. router.tsx, seo.ts, messages.ts, legal.ts and styles/ are uncovered. Set it at the top level and switch it off for src/analytics/**.
  • Removed safeguard. Form.tsx drops data-ph-no-capture with no replacement while autocapture and replay are on. src/legal.json now states the opposite of what runs. Documented as deliberate in the runbook; noted, not re-raised.

Baseline smells (all judgement calls)

  • Duplicated Code: two near-identical throw blocks and two adjacent if (import.meta.env.DEV) blocks in AnalyticsProvider.tsx. One requireEnv(name, value) would cover both.
  • Speculative Generality: tracing_headers: [window.location.hostname] stamps PostHog headers on the /api/assessment POST and loads an extra script. capture_exceptions: true has no stated need. Both are wizard defaults.
  • Verified fine: PostHogProvider calls init inside useEffect, so wrapping <Scripts /> does not run PostHog during prerender, and tracing_headers is [] on the server with no hydration mismatch.

Spec

Breaks #31, and the PR says so

  • "Autocapture, surveys, heatmaps and session replay are all disabled." None are set. One nuance the description gets wrong: replay, heatmaps and dead clicks are not "on" in code. posthog-js leaves them undefined, so they are whatever the project's remote config says. The runbook also dropped What does PostHog track, and how is it initialised? #13's "leave Record user sessions off" and "Discard client IP data" steps.
  • "Link and Disclosure expose an optional analytics hook, so sections do not import the analytics module directly." Sections now import useTrack. Declared, and lint-enforced.
  • The dev throw on missing variables vs "an empty project token makes initialisation a no-op". Declared as a follow-up.

Breaks #31 silently

  • "The analytics module is loaded on idle and does not compete with LCP" and "measured bundle cost is recorded against the performance budget." posthog-js is now a static import reached from __root, so it sits in the eager entry chunk. init runs in the hydration effect, not on idle. No bundle number appears anywhere in the diff.

    chunk raw gzip
    entry index-*.js after this PR 626.8 kB 204.8 kB
    posthog-js chunk on main, idle-loaded 90.2 kB
  • "Cookieless mode is on with person profiles never created." person_profiles: 'never' and ui_host were dropped; the default is identified_only. defaults went backwards from '2026-05-30' to '2025-05-24'.

  • Undeclared event renames: assessment_failed became assessment_submission_failed, and email_fallback_used{method} split into assessment_details_copied and assessment_email_fallback_opened. The copy event now fires only on clipboard success, so failed copies go uncounted. That weakens the audit What does PostHog track, and how is it initialised? #13 created the event for ("does the recovery path actually recover leads").

  • .env.example header still says "every value below works with no account" above placeholders that do not.

Confirmed against the code

Client-only init, cookieless_mode: 'always', capture_exceptions: true, no identify() call anywhere, the stable FAQ slug, 'delivery' present in the endpoint's error union, and the variable rename consistent between deploy.yml and the runbook. Field values still do not leak: autocapture skips input/select/textarea and replay's maskAllInputs defaults to true.

Unrequested additions

capture_exceptions, tracing_headers, the dev-only window.posthog global, vite fmt/lint ignore rules for .claude/skills/**, roughly 530 lines of vendored wizard docs, and the posthog-js bump. The vendored docs include identify-users.md, which teaches the call useTrack.ts forbids.

Follow-up outside the diff

Issue #34's step 7 still names the old VITE_POSTHOG_PROJECT_TOKEN variable.


Summary. Standards: 2 hard, 3 judgement calls, 3 smells; worst is the broken "no accounts" constraint. Spec: 3 declared deviations, 5 silent ones, 6 unrequested additions; worst is PostHog moving into the eager bundle with no recorded cost. The rename commit is unreviewed.

The serious one: .env.example shipped literal <ph_project_token> and
<ph_client_api_host>. Those strings are truthy, so a fresh clone that copied
the file past the guard in AnalyticsProvider and initialised PostHog against a
host literally named "<ph_client_api_host>" -- under a header promising "every
value below works with no account". The token is now blank and the host is the
real US default.

Blanking it alone would have traded one break for another, because the
DEV-only guards threw and stopped the dev server. They now warn. Verified by
blanking both variables: the page renders, PostHog never initialises, zero
requests reach posthog.com, and two warnings explain why.

Config values dropped when the wizard's options were carried across verbatim:

- person_profiles: 'never' -- had silently reverted to identified_only
- ui_host -- only consulted behind a reverse proxy, which is planned; setting
  it now means switching api_host is a one-variable change
- defaults: '2026-05-30' -- had gone backwards to '2025-05-24'
- capture_pageview: 'history_change' -- pinned rather than inherited

Dropped tracing_headers: it stamped PostHog headers on the /api/assessment
POST and was never asked for.

assessment_details_copied fired only in the clipboard success branch, so a
rejected write went uncounted -- and that event exists to answer whether the
recovery panel actually recovers leads (issue #13), which makes the failures
the interesting half. It now fires on the attempt.

The no-restricted-imports boundary covered routes, sections and base only,
leaving router.tsx, seo.ts, messages.ts, legal.ts and styles/ outside it. It
now applies to all of src/ and is switched off for src/analytics/. Verified
both ways: an import in seo.ts is caught, src/analytics/ stays exempt.

Also removes 526 lines of vendored wizard reference docs -- one of which
teaches the identify() call useTrack.ts forbids -- and with them the fmt and
lint ignore rules that existed only to accommodate them. Fixes a stale event
name in the provisioning runbook.
Workers Builds is connected to the repository and deploys on push to main, so
deploy.yml was a second path to production that would have raced it the moment
CLOUDFLARE_API_TOKEN existed. Removed. ci.yml already runs install, format,
lint, types, build and the placeholder report on every push and pull request,
so nothing is lost.

Two things did not survive the move on their own:

- The hard placeholder gate lived in deploy.yml. Workers Builds knows nothing
  about it, so it is now `build:deploy` in package.json, and the Cloudflare
  build command points at that. Verified it blocks: with a bracketed value
  present the script exits 1 and vite never runs.
- The VITE_ build variables came from GitHub Actions variables. They are
  Cloudflare build variables now. The runbook's variable map says so, and step
  1 documents where they go.

Also drops the `deploy` script. A local `wrangler deploy` builds against .env,
where VITE_SITE_URL is http://localhost:3000, and src/seo.ts falls back to that
same value when unset -- which is why the first deployment shipped canonical and
og:url pointing at localhost on every prerendered page. Deployment belongs to
Workers Builds; there is no longer a one-command way to bypass it by accident.

wrangler.jsonc states workers_dev and preview_urls explicitly rather than
letting them default with a warning on every deploy.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 7, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
execil 0b1bac1 Commit Preview URL

Branch Preview URL
Sep 07 2026, 11:25 AM

Workers Builds runs the Build command for preview versions as well as for
production, so a gate there would fail every preview -- the copy ships with
bracketed placeholders deliberately until launch, which is why deploy.yml
gated the deploy step and not the build.

The Deploy command is production-only, so the gate lives there:
`deploy:production` is `placeholders && wrangler deploy`. Build and Version
commands stay bare.

Verified both halves with a placeholder present: `pnpm run build` succeeds, so
previews keep working, and `pnpm run deploy:production` exits 1 before wrangler
runs.

The runbook now records all four dashboard fields, since that configuration
lives only in Cloudflare and nothing in the repository would otherwise say what
it should be.
Step 8 asked for a rule on POST /api/assessment. Rate limiting on the Free plan
matches Path and Verified Bot only -- Method needs Business -- and allows one
rule per zone with the period capped at 10 seconds. The route declares a POST
handler and nothing else, so matching the path alone is equivalent here.

Also finishes the GitHub-variable to Cloudflare-build-variable rename in the
step bodies; the variable map was already updated.
SENDER_DOMAIN is the bare apex, execil.net, even though Email Routing goes on
the subdomain: the route composes assessment@forms.${SENDER_DOMAIN} itself.

The EMAIL binding is declared without destination_address. With one set the
binding is pinned to a single recipient and a `to` that differs is rejected,
which is the safer shape -- but it has to be an address already verified in the
Cloudflare account, and it must match ASSESSMENT_TO exactly. Both land together
once step 5 is done.

ASSESSMENT_TO is still placeholder@example.com, so submissions fail with reason
"delivery" until it is a verified destination. That is the designed failure
path, not a regression: the form's recovery panel covers it.

TURNSTILE_SECRET stays out of this file and out of vars -- it is a secret and
this file is committed.

Verified with `wrangler deploy --dry-run`, which now reports env.EMAIL,
env.ASSESSMENT_TO and env.SENDER_DOMAIN. Note the dry-run reads
dist/server/wrangler.json, which the Cloudflare vite plugin regenerates at
build time -- it reports the previous config until you rebuild.
Cloudflare Email Routing cannot be scoped to a subdomain without onboarding the
apex first: the dashboard reaches Settings > Subdomains only through the apex
domain, and its "Verify DNS records" screen replaces the apex MX with
route1/2/3.mx.cloudflare.net and the apex SPF with Cloudflare's include, offering
to remove the conflicting records "in place". Those are the records carrying the
Hostinger mailbox. Email Sending avoids the MX but writes an apex-wide _dmarc,
and needs a paid Workers plan.

So the endpoint POSTs to Resend and sends from a subdomain, which keeps every
verification record on forms.execil.net and the apex untouched.

The `!sent.ok` check matters: fetch only rejects on network failure, so without
it a 4xx from Resend would look like success and the visitor would be told their
request was delivered when it was not.

Verified locally against the real API with Cloudflare's always-passes Turnstile
test secret:

- no key           -> {"ok":false,"error":"delivery"}, HTTP 502
- key rejected 401 -> {"ok":false,"error":"delivery"}, HTTP 502

and the failure log still carries field NAMES only -- no address, practice name
or message body -- so issue #12's guarantee survives the rewrite.

Removes the send_email binding; SENDER_DOMAIN stays, since the route still
composes assessment@forms.${SENDER_DOMAIN}. Runbook steps 4 and 5 rewritten, and
the standing DNS warning now names what actually threatens the mailbox.
Matches the address the site already publishes as its contact and uses for the
form's mailto fallback (VITE_CONTACT_EMAIL, messages.json), so a visitor who
hits the failure panel writes to the same mailbox the endpoint would have
delivered to.

Plain text rather than a secret on purpose: it is published on the site, and a
reviewer should be able to see where leads go without opening a dashboard.

Resend delivers to it like any other recipient -- there is no verification step
for the RECIPIENT, unlike the Cloudflare binding this replaced, where the
destination had to be verified in the account first. What still needs verifying
is the SENDER domain, forms.execil.net, in Resend.
The subdomain existed to satisfy Cloudflare Email Routing, which needed a
routing domain that was not the apex. Resend has no such requirement: it places
its own MX and SPF on a send.execil.net subdomain and DKIM on
resend._domainkey, and an MX record only affects the name it sits on, so the
apex MX and SPF carrying the Hostinger mailbox are untouched either way. The
subdomain was left-over scaffolding from a design that is gone.

SENDER_DOMAIN is replaced by RESEND_FROM holding the whole From header rather
than a domain the route reassembles, matching how the britnova project
configures the same provider. The sender is now
"Execil Assessment <assessment@execil.net>".

Runbook step 4 rewritten to verify the apex in Resend, with an explicit warning
not to add a Resend include: to the apex SPF -- it is not needed and a domain
may hold only one SPF policy.
Same call britnova makes. Drops the manual Authorization header, the JSON
body and the !response.ok check, and removes the reply_to/replyTo ambiguity --
the SDK takes replyTo and maps it.

Kept the error check, in a different form: the SDK RESOLVES on an API error and
returns it in `error` rather than throwing, so without `if (error) throw` a
rejected send reads as success and the visitor is told their request was
delivered.

Verified the SDK runs in workerd, not just that it type-checks: an invalid key
through the local Worker returns {"ok":false,"error":"delivery"} with HTTP 502,
and the failure log still carries field names only.
CONTEXT.md still defined the Inbox as a Google Workspace mailbox. It is
Hostinger, at contact@execil.net, and it is now a destination only -- sending is
Resend's, and the separation is what keeps the apex MX from having to move.

Two in the runbook: the launch check still said "the Workspace inbox", and
step 6 still told you to run `wrangler secret put`, which needs a local wrangler
login that the Workers Builds path does not create. Both secrets go in the
dashboard now.

Checked and deliberately left alone:

- Email Routing and send_email appear in wrangler.jsonc and the runbook's DNS
  warning as the reason they are NOT used. That is rationale, not rot.
- No dead code. The 22 exports nothing else imports are design-system prop
  unions plus getRouter, which the router entry and routeTree.gen.ts use.
- react-dom is not an unused dependency; it is pulled in by the framework and
  appears 24 times in the built worker.
- .env.example and the VITE_ variables the code reads are exactly in sync.
A real key in .dev.vars was one submit away from putting test mail in the
client's inbox. Local runs now log the message and report success instead, so
the form's success path stays exercisable offline without sending anything.

The guard is `import.meta.env.DEV`, which is statically false in a production
build, so the branch is removed at build time rather than shipped behind a
runtime check that a spoofed header could reach. Confirmed: "DRY RUN" appears
nowhere in dist/, and api.resend.com is still present in the server chunk.

Proved it does not call out by putting an INVALID key in .dev.vars -- a real
call would have returned error "delivery", and the endpoint returned ok:true
with the message logged.

Also marks two research documents as superseded. Both still describe designs
that are no longer built, and worker-to-workspace-email.md describes one that
would break the client's mail if followed: Cloudflare Email Routing has no
subdomain-only path, so onboarding replaces the apex MX. Their findings stay
accurate for their date and are kept as a record of what was checked.
It was written before any account existed, so much of it was reasoned rather
than observed, and it has already been wrong about Email Routing subdomains,
the mailbox provider, Free-plan rate-limiting fields and wrangler secret put.
One of those would have replaced the apex MX and taken down the client's
mailbox.

Anyone following it should check the vendor's own documentation on any conflict
and correct this file in the same change.
Cloudflare's own integration flow requires three checks. We did one. From
https://developers.cloudflare.com/turnstile/spin/prompt.md: reject unless
`success` is true, `action` matches, and `hostname` is in an approved list,
where "production deployments must exclude localhost and 127.0.0.1".

The hostname check is the one that mattered. Turnstile adds localhost and
127.0.0.1 to every widget automatically and they cannot be removed, and the
sitekey is public in the page source -- so without it anyone could solve a real
challenge on their own machine and spend the token against production. The
field was already being read into the verdict type and then ignored.

Also adds the `action` binding, an AbortSignal.timeout(10s) so a hung siteverify
cannot hang the submission with it, and switches the request to urlencoded to
match the documented idiom. remoteip stays CF-Connecting-IP, which Cloudflare
sets itself and a client cannot forge.

Both new checks are skipped in DEV, because they cannot be satisfied there:
siteverify with Cloudflare's TEST keys answers hostname "example.com" and no
`action` at all, whatever the widget was rendered with. Found that by asking
siteverify directly after the strict version broke local submissions. `success`
is still required, and import.meta.env.DEV is statically false in a production
build so the relaxation cannot ship.

Verified locally end to end: a real widget-minted token now returns ok:true.
@Abuubkar
Abuubkar merged commit a9df27e into main Sep 7, 2026
3 checks passed
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