Skip to content

fix(events): give PostHog the visitor's user agent and country - #21

Merged
jm-rivera merged 9 commits into
devfrom
fix/posthog-event-context
Oct 20, 2026
Merged

jm-rivera merged 9 commits into
devfrom
fix/posthog-event-context

Conversation

@jm-rivera

@jm-rivera jm-rivera commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Server-side events sent to PostHog carry the browser's $raw_user_agent and Cloudflare country, so bot classification and country breakdowns reflect the visitor. GeoIP is disabled and the visitor's IP is omitted. Events with neither a visitor id nor an account id receive a fresh UUID and $process_person_profile: false. Client-supplied properties beginning with $ are stripped before forwarding.

All six server callers pass the incoming request headers, including the sign-in callback, share page loads, and embed renders. The embed page reads its headers during render for use in after(). The AI policy and analytics documentation describe the country and browser data collected.

Search runs and edits record their source, post number, trace id, duration, and outcome. Tracking covers failures, abandonment, and actions on the answer displayed when the action starts. Share creation and opening are recorded, and each usage event is also written as a structured JSON line for Cloud Logging.

Visitor-to-account linking and browser page/session enrichment remain outside this change.

Testing: All 1,613 tests passed locally against PostgreSQL 18. pnpm typecheck, pnpm lint, pnpm lint:css, pnpm build:migrate, and the migration-bundle smoke test passed. GitHub CI passed, including the production build.

PostHog classed every server-sent event as a bot, placed every visitor
at the Cloud Run egress in Brussels, and merged all unconsented
visitors into one 'anonymous' person. Each track() caller now passes
the browser request's headers, so sendEventToPosthog() can send
$raw_user_agent, $ip for consented visitors only, and $geoip_disable
otherwise. An event with no visitor id and no user id gets a random
distinct_id and $process_person_profile false. Event props can no
longer set PostHog's $ properties.
GA4 already locates consented visitors, so PostHog needs only the
country, joined to product events. Every event now carries `country`
from the CF-IPCountry header and sets $geoip_disable, and no event
sends the visitor's IP.
Search events fired once per query text from a React effect, so
edits, repeats, failures and abandoned searches recorded nothing, and
no event could be joined to the engine's log line. Each post (the
base run and every edit) now gets a tracker in useSearchRun that sends
exactly one terminal event from the post's own stream: search.query or
search.empty for a base run, search.edit for an edit, search.error, or
search.abandon (hidden, superseded or reset). Every event carries
searchId, post, source, traceId, the client and engine times and the
engine's outcome. search.action records reading picks, downloads and
shares, search.start_point replaces the starting point's search.query,
and shareSearch now sends share.create with kind search.
Nothing counted a share link being opened: embeds had embed.view, but
the /s/<id> page sent no event. The page now records share.open with
shareId, kind, mode and the referring host, server-side and with no
visitor or user id, scheduled before a search share's redirect.
refererHost moves to src/lib/events/referer.ts so the embed page and
the share page share it.
…on ids

track() now writes each event as one JSON log line (message "event",
name, props, page path, signedIn, no user or visitor id) before the
database insert and the PostHog forward, so events can be joined to
the dfo-apis resolve line by traceId in SQL.

Leaving the search page mid-search ended no post, and the departed
post later sent its result: the hook now ends active posts as a
"left" abandon and aborts their streams on a real unmount, deferred
one microtask so Strict Mode's dev remount does not abort a permalink
run. Share and download actions read the search ids when the request
starts, not when it lands. The documented true-abandon query now also
excludes posts that later ended as superseded, reset or left.
The browser track() read location.pathname when it sent, so a post
abandoned by navigating away was recorded under the destination page.
Each post and each search action now captures its page when it starts
and sends that page on every event; track() takes an optional page.
crypto.randomUUID exists only in secure contexts, so search ids and
the visitor id threw on the LAN dev server; both use uuid().

A share lookup or ?s= decode that resolved after the visitor left
/data still started a search and sent its events. The unmount guard
now exposes left(), and startRun and showStartingPoint refuse to start
once it is set, which covers every link initialiser.

The homepage prompt sends /data?q=&via=typed|example. /data reads via
once, strips it from the address bar, and records that source, so only
a shared or bookmarked ?q= link records permalink.
@jm-rivera
jm-rivera marked this pull request as ready for review October 20, 2026 15:26
…-tracking

feat(search): send one usage event per search post, joined by trace id
Preserve search source tracking with streamed suggestions, and retain
share.open and gallery.open in the event registry.
@jm-rivera
jm-rivera merged commit fb02ee6 into dev Oct 20, 2026
1 check 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