From cc2fc6f9e94e0887a2c272b13b5fa8fcbe169a1a Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 15 Oct 2026 10:09:04 +0200 Subject: [PATCH 1/7] fix(events): give PostHog the visitor's user agent and location 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. --- docs/analytics.md | 6 +- src/app/(site)/ai-policy/page.tsx | 6 +- src/app/api/events/route.ts | 1 + src/app/api/feedback/route.ts | 2 +- src/app/api/share/route.ts | 2 +- src/app/embed/[id]/page.tsx | 5 +- src/lib/auth/auth.ts | 2 +- src/lib/events/posthog.ts | 48 +++++++++++++--- src/lib/events/track.ts | 9 ++- src/lib/test/events-posthog.test.ts | 88 +++++++++++++++++++++++++---- 10 files changed, 142 insertions(+), 27 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index 4e6e8a7..6fce10a 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -4,7 +4,7 @@ Usage events go to our own database, errors to Sentry, and page views to Google ## Usage events -The events DFO Overview asks for (Data access → "Events to record") land in the `event` table (`db/schema.ts`, migration `0006_events`): one row per event with a dotted `name`, the event's own `props` (JSONB), `user_id` when signed in, a random per-browser `visitor_id` (localStorage, no cookie) when the visitor has consented, and the `page`. No event holds an IP address or the text of a Studio prompt. The three search events hold the first 500 characters of the query. +The events DFO Overview asks for (Data access → "Events to record") land in the `event` table (`db/schema.ts`, migration `0006_events`): one row per event with a dotted `name`, the event's own `props` (JSONB), `user_id` when signed in, a random per-browser `visitor_id` (localStorage, no cookie) when the visitor has consented, and the `page`. No event row holds an IP address or the text of a Studio prompt. The three search events hold the first 500 characters of the query. The browser stores `visitor_id` only after the visitor accepts "DFO usage statistics" in the Analytics category of the cookie banner (Usercentrics service `L3eMCSMT3WELG7`, read through the CMP's `window.__ucCmp` API and `UC_CONSENT` event in `src/lib/consent.ts`). Until then, including while the banner is loading or blocked, events arrive with a null visitor id. Withdrawing consent deletes the stored id. @@ -23,6 +23,8 @@ The browser stores `visitor_id` only after the visitor accepts "DFO usage statis Browser code calls `track()` from `src/lib/events/client.ts`, which posts to `POST /api/events` (allow-listed names, props capped at 8 KB, always 204); server code calls `track()` from `src/lib/events/track.ts` directly. The list of names lives in `track.ts` — add there first. +When `POSTHOG_KEY` and `POSTHOG_HOST` are set, `track()` also forwards each event server-side to PostHog (EU cloud) through `sendEventToPosthog()` in `src/lib/events/posthog.ts`. The event keeps its props, minus any key starting with `$`, and adds `environment`, `page` and `signed_in`. Its `distinct_id` is the visitor id for a consented visitor, else the account id for a signed-in one. An event with neither gets a fresh random UUID and `$process_person_profile: false`, so PostHog creates no person for it. Each event carries the browser request's `User-Agent` as `$raw_user_agent`, which PostHog's bot filter reads. A consented visitor's event also carries `$ip`, the address that `clientAddress()` in `src/lib/proxy/caller.ts` reads from `X-Forwarded-For` with `TRUSTED_PROXY_HOPS`. PostHog uses it for geolocation and, with IP anonymisation on in the project, drops it afterwards. Every other event sets `$geoip_disable: true`, because PostHog would otherwise place it at the Cloud Run server. All five server callers pass the browser request's headers: `POST /api/events`, feedback, Studio shares, embed renders and the sign-in callback. An event with no request behind it passes `headers: null` and carries neither field. + A resolved part records what the question asked for, and counts the StatVars and places it expanded to: an aid query expands to hundreds of each, which would pass the 8 KB cap. A role asked for as a named group, such as the European Union, records the group, three of its members and the member count. A set-valued filter with more than three refs records three and the count. `sources` lists every source id while the whole event fits the cap, and the first three per part when it does not; `sourceCount` is always the full number, so a list shorter than the count is a sample. If the event still exceeds the cap, each resolved part keeps only `specId`, `measure`, `statVarCount`, `entityCount`, `sourceCount` and the per-role place `count`, and the event carries `truncated: true`. If that form is still too large, the event ends on a fixed-size floor: an empty `query`, no refused parts, and per resolved part only a clipped `specId` and the integer counts. The ladder runs on the complete props, `seeds` included, and measures the JSON length as the route does; the API's limit of six parts bounds the floor. The table is meant to be exported to BigQuery alongside the API usage log lines; until then it is plain SQL, e.g. the most-requested measures: @@ -50,7 +52,7 @@ Embeds load neither Usercentrics nor GA. On an `/embed/` path (`isEmbedPath()` i The "Privacy settings" button in the footer (`src/components/organisms/Footer/Footer.tsx`) calls `showPrivacySettings()` from `src/lib/consent.ts`, which opens the Usercentrics settings layer so a visitor can change either choice after the first banner. -Enhanced measurement records page views on App Router navigations (history changes), outbound clicks, scrolls, site search (`?q=`) and file downloads, so the site sends GA only its config call. The `track()` events above go to our own database alone. +Enhanced measurement records page views on App Router navigations (history changes), outbound clicks, scrolls, site search (`?q=`) and file downloads, so the site sends GA only its config call. The `track()` events above go to our own database and PostHog; GA receives none of them. To check it on production, open the network panel and filter on `google-analytics.com/g/collect`. Before consent the requests carry `gcs=G100`, and after accepting in the banner they carry `gcs=G111`. The visit then shows in GA under Reports → Realtime. Localhost and dev make no `gtag/js` request. diff --git a/src/app/(site)/ai-policy/page.tsx b/src/app/(site)/ai-policy/page.tsx index 32a6548..698d614 100644 --- a/src/app/(site)/ai-policy/page.tsx +++ b/src/app/(site)/ai-policy/page.tsx @@ -84,8 +84,10 @@ const AiPolicyPage = () => (
  • Internet addresses. Our servers’ request logs record the - internet address of each request, as on any website. Usage statistics do not - include it. + internet address of each request, as on any website. If you accepted usage + statistics, PostHog uses your address to estimate your country and city, then + discards it. Otherwise usage statistics leave it out. Usage statistics record + your browser’s name and version, to tell people from automated traffic.
  • diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index 9b52e7a..babc4ff 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -40,6 +40,7 @@ export const POST = async (request: Request) => { userId: session?.user.id ?? null, visitorId: clip(body?.visitorId, MAX_ID), page: clip(body?.page, MAX_PAGE), + headers: request.headers, }); return new NextResponse(null, { status: 204 }); diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts index 72117dc..3254755 100644 --- a/src/app/api/feedback/route.ts +++ b/src/app/api/feedback/route.ts @@ -95,7 +95,7 @@ export const POST = async (req: NextRequest) => { void track( 'feedback.submit', { kind: message.startsWith('Studio chat feedback') ? 'chat' : 'widget' }, - { page }, + { page, headers: req.headers }, ); return json(200, true, 'Thanks for your feedback!'); diff --git a/src/app/api/share/route.ts b/src/app/api/share/route.ts index 14f898f..f217c25 100644 --- a/src/app/api/share/route.ts +++ b/src/app/api/share/route.ts @@ -79,7 +79,7 @@ export const POST = async (request: Request) => { track( 'share.create', shareCreateProps({ id: created.id, kind: read.kind, mode: read.mode }), - { userId: user.id }, + { userId: user.id, headers: request.headers }, ), ); diff --git a/src/app/embed/[id]/page.tsx b/src/app/embed/[id]/page.tsx index e5ce227..85bc0e9 100644 --- a/src/app/embed/[id]/page.tsx +++ b/src/app/embed/[id]/page.tsx @@ -41,8 +41,9 @@ const EmbedPage = async ({ params }: EmbedPageProps) => { // Counted here, server-side, with no identifier and nothing stored on the // device, so it needs no consent. Headers are read during render: inside // after() they throw. - const host = refererHost((await headers()).get('referer')); - after(() => track('embed.view', { shareId: id, kind: row.kind, host })); + const incoming = await headers(); + const host = refererHost(incoming.get('referer')); + after(() => track('embed.view', { shareId: id, kind: row.kind, host }, { headers: incoming })); return ; }; diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index 4e3f7b9..b4fcb6a 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -89,7 +89,7 @@ export const auth = betterAuth({ await track( 'auth.signin', { provider: ctx.params?.id ?? null }, - { userId: session.user.id }, + { userId: session.user.id, headers: ctx.headers ?? null }, ); } }), diff --git a/src/lib/events/posthog.ts b/src/lib/events/posthog.ts index dfbec21..23598e7 100644 --- a/src/lib/events/posthog.ts +++ b/src/lib/events/posthog.ts @@ -1,3 +1,6 @@ +import { randomUUID } from 'node:crypto'; + +import { clientAddress, trustedHops } from '../proxy/caller.ts'; import type { EventName, EventProps, Who } from './track'; // The PostHog half of src/lib/events, in its own module (no db or server-only imports) so src/lib/test/events-posthog.test.ts can exercise it under plain node. @@ -5,28 +8,59 @@ import type { EventName, EventProps, Who } from './track'; export const posthogConfigured = (): boolean => Boolean(process.env.POSTHOG_KEY && process.env.POSTHOG_HOST); +// The visitor's address for a consented visitor (see clientAddress in +// src/lib/proxy/caller), else undefined. A malformed TRUSTED_PROXY_HOPS +// gives undefined rather than a guess. +const consentedAddress = (who: Who): string | undefined => { + const hops = trustedHops(process.env); + + if (!who.visitorId || !who.headers || typeof hops !== 'number') { + return undefined; + } + + return clientAddress(who.headers.get('x-forwarded-for'), hops); +}; + /** * Forward one event to PostHog's capture endpoint. One project serves every * environment, so each event carries ENVIRONMENT (set by dfo-infra) to say - * where it came from. distinct_id is the consent-gated random visitor id - * when the browser sent one, else the account id (pseudonymous, the same - * value the event table stores), else a shared bucket for server-only - * events; signed_in lets dashboards split guests from account holders - * without either id. + * where it came from; signed_in lets dashboards split guests from account + * holders without either id. + * + * distinct_id is the consent-gated random visitor id when the browser sent + * one, else the account id (pseudonymous, the same value the event table + * stores). An event with neither gets a fresh random id and + * $process_person_profile false, so it creates no person. + * + * $raw_user_agent is the request's User-Agent, which PostHog's bot filter + * reads. $ip is the client address for a consented visitor only; every + * other event sets $geoip_disable, since PostHog would otherwise locate the + * server. An event with no browser request behind it (who.headers null) + * carries neither. Event props cannot set PostHog's `$` properties. */ export const sendEventToPosthog = async (name: EventName, props: EventProps, who: Who) => { + const person = who.visitorId ?? who.userId; + const userAgent = who.headers?.get('user-agent'); + const ip = consentedAddress(who); + const ownProps = Object.fromEntries( + Object.entries(props).filter(([key]) => !key.startsWith('$')), + ); + const response = await fetch(`${process.env.POSTHOG_HOST}/i/v0/e/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: process.env.POSTHOG_KEY, event: name, - distinct_id: who.visitorId ?? who.userId ?? 'anonymous', + distinct_id: person ?? randomUUID(), properties: { - ...props, + ...ownProps, environment: process.env.ENVIRONMENT ?? 'unknown', page: who.page ?? null, signed_in: Boolean(who.userId), + ...(userAgent && { $raw_user_agent: userAgent }), + ...(ip ? { $ip: ip } : { $geoip_disable: true }), + ...(!person && { $process_person_profile: false }), }, timestamp: new Date().toISOString(), }), diff --git a/src/lib/events/track.ts b/src/lib/events/track.ts index 6b48512..db09f37 100644 --- a/src/lib/events/track.ts +++ b/src/lib/events/track.ts @@ -45,10 +45,17 @@ export type EventName = (typeof EVENT_NAMES)[number]; export type EventProps = Record; +/** + * Who an event is about. `headers` are the incoming browser request's, for + * the user agent and client address forwarded to PostHog (see ./posthog), + * or null for an event with no browser request behind it. Required, so a + * caller with a request cannot leave it out. + */ export type Who = { userId?: string | null; visitorId?: string | null; page?: string | null; + headers: Headers | null; }; /** @@ -56,7 +63,7 @@ export type Who = { * failed insert or forward is logged and swallowed, and each sink fails * independently of the other. */ -export const track = async (name: EventName, props: EventProps = {}, who: Who = {}) => { +export const track = async (name: EventName, props: EventProps, who: Who) => { try { await db.insert(event).values({ name, diff --git a/src/lib/test/events-posthog.test.ts b/src/lib/test/events-posthog.test.ts index 929a081..f0134c9 100644 --- a/src/lib/test/events-posthog.test.ts +++ b/src/lib/test/events-posthog.test.ts @@ -19,6 +19,7 @@ test.beforeEach(() => { process.env.POSTHOG_KEY = 'phc_test'; process.env.POSTHOG_HOST = 'https://eu.i.posthog.example'; process.env.ENVIRONMENT = 'dev'; + process.env.TRUSTED_PROXY_HOPS = '2'; }); test.afterEach(() => { @@ -26,6 +27,7 @@ test.afterEach(() => { delete process.env.POSTHOG_KEY; delete process.env.POSTHOG_HOST; delete process.env.ENVIRONMENT; + delete process.env.TRUSTED_PROXY_HOPS; }); test('configured only when both halves are set', () => { @@ -44,7 +46,7 @@ test('sends the capture payload with the environment stamped', async () => { await sendEventToPosthog( 'search.query', { spec_ids: ['a', 'b'], places: 3 }, - { visitorId: 'v-123', userId: 'u-456', page: '/data' }, + { visitorId: 'v-123', userId: 'u-456', page: '/data', headers: null }, ); assert.equal(captured.length, 1); @@ -58,20 +60,86 @@ test('sends the capture payload with the environment stamped', async () => { assert.equal(props.page, '/data'); assert.equal(props.signed_in, true); assert.deepEqual(props.spec_ids, ['a', 'b']); - assert.ok(typeof body.timestamp === 'string' && !Number.isNaN(Date.parse(body.timestamp as string))); + assert.ok( + typeof body.timestamp === 'string' && !Number.isNaN(Date.parse(body.timestamp as string)), + ); }); -test('distinct_id falls back from visitor to user to anonymous', async () => { +const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Firefox/131.0'; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +// A browser request behind two load balancers (TRUSTED_PROXY_HOPS=2), with +// a forged entry the client put in front. +const browserHeaders = () => + new Headers({ + 'user-agent': UA, + 'x-forwarded-for': '198.51.100.66, 203.0.113.9, 34.1.1.1, 35.2.2.2', + }); + +const visitors = [ + { + kind: 'consented', + who: { visitorId: 'v-123' }, + distinctId: 'v-123', + ip: '203.0.113.9', + personless: false, + }, + { + kind: 'signed in without consent', + who: { userId: 'u-789' }, + distinctId: 'u-789', + ip: undefined, + personless: false, + }, + { kind: 'anonymous', who: {}, distinctId: UUID, ip: undefined, personless: true }, +] as const; + +for (const visitor of visitors) { + test(`the ${visitor.kind} visitor's payload`, async () => { + const captured: Captured[] = []; + stubFetch(200, captured); + + // A browser cannot set PostHog's own properties through event props. + const props = { $ip: '192.0.2.1', $process_person_profile: true, kind: 'chat' }; + await sendEventToPosthog('feedback.submit', props, { + ...visitor.who, + headers: browserHeaders(), + }); + await sendEventToPosthog('feedback.submit', props, { + ...visitor.who, + headers: browserHeaders(), + }); + + const [first, second] = captured.map((call) => call.body); + const properties = first.properties as Record; + + if (typeof visitor.distinctId === 'string') { + assert.equal(first.distinct_id, visitor.distinctId); + assert.equal(second.distinct_id, visitor.distinctId); + } else { + assert.match(String(first.distinct_id), visitor.distinctId); + assert.match(String(second.distinct_id), visitor.distinctId); + assert.notEqual(first.distinct_id, second.distinct_id); + } + + assert.equal(properties.$raw_user_agent, UA); + assert.equal(properties.$ip, visitor.ip); + assert.equal(properties.$geoip_disable, visitor.ip ? undefined : true); + assert.equal(properties.$process_person_profile, visitor.personless ? false : undefined); + assert.equal(properties.kind, 'chat'); + }); +} + +test('an event with no browser request carries no user agent or address', async () => { const captured: Captured[] = []; stubFetch(200, captured); - await sendEventToPosthog('auth.signin', {}, { userId: 'u-789' }); - await sendEventToPosthog('feedback.submit', {}, {}); + await sendEventToPosthog('embed.view', {}, { visitorId: 'v-123', headers: null }); - assert.equal(captured[0].body.distinct_id, 'u-789'); - assert.equal((captured[0].body.properties as Record).signed_in, true); - assert.equal(captured[1].body.distinct_id, 'anonymous'); - assert.equal((captured[1].body.properties as Record).signed_in, false); + const properties = captured[0].body.properties as Record; + assert.equal(properties.$raw_user_agent, undefined); + assert.equal(properties.$ip, undefined); + assert.equal(properties.$geoip_disable, true); }); test('a non-ok answer throws so track() can log it', async () => { @@ -79,7 +147,7 @@ test('a non-ok answer throws so track() can log it', async () => { stubFetch(500, captured); await assert.rejects( - () => sendEventToPosthog('guest.wall_hit', {}, {}), + () => sendEventToPosthog('guest.wall_hit', {}, { headers: null }), /PostHog answered 500/, ); }); From 33c0f50b71afd58c59a764c4dffd53c90dc86438 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 15 Oct 2026 10:15:50 +0200 Subject: [PATCH 2/7] fix(events): send Cloudflare's country to PostHog in place of the IP 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. --- docs/analytics.md | 2 +- src/app/(site)/ai-policy/page.tsx | 7 ++--- src/lib/events/posthog.ts | 29 ++++++++---------- src/lib/test/events-posthog.test.ts | 46 +++++++++++++++++------------ 4 files changed, 43 insertions(+), 41 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index 6fce10a..5b4174b 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -23,7 +23,7 @@ The browser stores `visitor_id` only after the visitor accepts "DFO usage statis Browser code calls `track()` from `src/lib/events/client.ts`, which posts to `POST /api/events` (allow-listed names, props capped at 8 KB, always 204); server code calls `track()` from `src/lib/events/track.ts` directly. The list of names lives in `track.ts` — add there first. -When `POSTHOG_KEY` and `POSTHOG_HOST` are set, `track()` also forwards each event server-side to PostHog (EU cloud) through `sendEventToPosthog()` in `src/lib/events/posthog.ts`. The event keeps its props, minus any key starting with `$`, and adds `environment`, `page` and `signed_in`. Its `distinct_id` is the visitor id for a consented visitor, else the account id for a signed-in one. An event with neither gets a fresh random UUID and `$process_person_profile: false`, so PostHog creates no person for it. Each event carries the browser request's `User-Agent` as `$raw_user_agent`, which PostHog's bot filter reads. A consented visitor's event also carries `$ip`, the address that `clientAddress()` in `src/lib/proxy/caller.ts` reads from `X-Forwarded-For` with `TRUSTED_PROXY_HOPS`. PostHog uses it for geolocation and, with IP anonymisation on in the project, drops it afterwards. Every other event sets `$geoip_disable: true`, because PostHog would otherwise place it at the Cloud Run server. All five server callers pass the browser request's headers: `POST /api/events`, feedback, Studio shares, embed renders and the sign-in callback. An event with no request behind it passes `headers: null` and carries neither field. +When `POSTHOG_KEY` and `POSTHOG_HOST` are set, `track()` also forwards each event server-side to PostHog (EU cloud) through `sendEventToPosthog()` in `src/lib/events/posthog.ts`. The event keeps its props, minus any key starting with `$`, and adds `environment`, `page`, `signed_in` and `country`. Its `distinct_id` is the visitor id for a consented visitor, else the account id for a signed-in one. An event with neither gets a fresh random UUID and `$process_person_profile: false`, so PostHog creates no person for it. Each event carries the browser request's `User-Agent` as `$raw_user_agent`, which PostHog's bot filter reads. `country` is the two-letter code Cloudflare puts in the `CF-IPCountry` header, or null when Cloudflare has no location (`XX`) or the visitor uses Tor (`T1`). Every event sets `$geoip_disable: true`, because PostHog would otherwise place it at the Cloud Run server, and no event carries the visitor's IP. All five server callers pass the browser request's headers: `POST /api/events`, feedback, Studio shares, embed renders and the sign-in callback. An event with no request behind it passes `headers: null` and carries no user agent and a null `country`. A resolved part records what the question asked for, and counts the StatVars and places it expanded to: an aid query expands to hundreds of each, which would pass the 8 KB cap. A role asked for as a named group, such as the European Union, records the group, three of its members and the member count. A set-valued filter with more than three refs records three and the count. `sources` lists every source id while the whole event fits the cap, and the first three per part when it does not; `sourceCount` is always the full number, so a list shorter than the count is a sample. If the event still exceeds the cap, each resolved part keeps only `specId`, `measure`, `statVarCount`, `entityCount`, `sourceCount` and the per-role place `count`, and the event carries `truncated: true`. If that form is still too large, the event ends on a fixed-size floor: an empty `query`, no refused parts, and per resolved part only a clipped `specId` and the integer counts. The ladder runs on the complete props, `seeds` included, and measures the JSON length as the route does; the API's limit of six parts bounds the floor. diff --git a/src/app/(site)/ai-policy/page.tsx b/src/app/(site)/ai-policy/page.tsx index 698d614..61c690b 100644 --- a/src/app/(site)/ai-policy/page.tsx +++ b/src/app/(site)/ai-policy/page.tsx @@ -84,10 +84,9 @@ const AiPolicyPage = () => (

  • Internet addresses. Our servers’ request logs record the - internet address of each request, as on any website. If you accepted usage - statistics, PostHog uses your address to estimate your country and city, then - discards it. Otherwise usage statistics leave it out. Usage statistics record - your browser’s name and version, to tell people from automated traffic. + internet address of each request, as on any website. Usage statistics leave + it out. They record the country your request came from, and your browser’s + name and version, to tell people from automated traffic.
  • diff --git a/src/lib/events/posthog.ts b/src/lib/events/posthog.ts index 23598e7..3a1b399 100644 --- a/src/lib/events/posthog.ts +++ b/src/lib/events/posthog.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { clientAddress, trustedHops } from '../proxy/caller.ts'; import type { EventName, EventProps, Who } from './track'; // The PostHog half of src/lib/events, in its own module (no db or server-only imports) so src/lib/test/events-posthog.test.ts can exercise it under plain node. @@ -8,17 +7,12 @@ import type { EventName, EventProps, Who } from './track'; export const posthogConfigured = (): boolean => Boolean(process.env.POSTHOG_KEY && process.env.POSTHOG_HOST); -// The visitor's address for a consented visitor (see clientAddress in -// src/lib/proxy/caller), else undefined. A malformed TRUSTED_PROXY_HOPS -// gives undefined rather than a guess. -const consentedAddress = (who: Who): string | undefined => { - const hops = trustedHops(process.env); +// The ISO country code Cloudflare's edge puts in CF-IPCountry, or null. +// Cloudflare sends XX when it has no location and T1 for Tor. +const visitorCountry = (headers: Headers | null): string | null => { + const code = headers?.get('cf-ipcountry')?.trim().toUpperCase(); - if (!who.visitorId || !who.headers || typeof hops !== 'number') { - return undefined; - } - - return clientAddress(who.headers.get('x-forwarded-for'), hops); + return code && /^[A-Z]{2}$/.test(code) && code !== 'XX' ? code : null; }; /** @@ -33,15 +27,15 @@ const consentedAddress = (who: Who): string | undefined => { * $process_person_profile false, so it creates no person. * * $raw_user_agent is the request's User-Agent, which PostHog's bot filter - * reads. $ip is the client address for a consented visitor only; every - * other event sets $geoip_disable, since PostHog would otherwise locate the - * server. An event with no browser request behind it (who.headers null) - * carries neither. Event props cannot set PostHog's `$` properties. + * reads. country is the visitor's country from Cloudflare. Every event sets + * $geoip_disable, since PostHog would otherwise locate the server, and no + * event carries the visitor's IP. An event with no browser request behind + * it (who.headers null) has no user agent and a null country. Event props + * cannot set PostHog's `$` properties. */ export const sendEventToPosthog = async (name: EventName, props: EventProps, who: Who) => { const person = who.visitorId ?? who.userId; const userAgent = who.headers?.get('user-agent'); - const ip = consentedAddress(who); const ownProps = Object.fromEntries( Object.entries(props).filter(([key]) => !key.startsWith('$')), ); @@ -58,8 +52,9 @@ export const sendEventToPosthog = async (name: EventName, props: EventProps, who environment: process.env.ENVIRONMENT ?? 'unknown', page: who.page ?? null, signed_in: Boolean(who.userId), + country: visitorCountry(who.headers), ...(userAgent && { $raw_user_agent: userAgent }), - ...(ip ? { $ip: ip } : { $geoip_disable: true }), + $geoip_disable: true, ...(!person && { $process_person_profile: false }), }, timestamp: new Date().toISOString(), diff --git a/src/lib/test/events-posthog.test.ts b/src/lib/test/events-posthog.test.ts index f0134c9..29f6b5c 100644 --- a/src/lib/test/events-posthog.test.ts +++ b/src/lib/test/events-posthog.test.ts @@ -19,7 +19,6 @@ test.beforeEach(() => { process.env.POSTHOG_KEY = 'phc_test'; process.env.POSTHOG_HOST = 'https://eu.i.posthog.example'; process.env.ENVIRONMENT = 'dev'; - process.env.TRUSTED_PROXY_HOPS = '2'; }); test.afterEach(() => { @@ -27,7 +26,6 @@ test.afterEach(() => { delete process.env.POSTHOG_KEY; delete process.env.POSTHOG_HOST; delete process.env.ENVIRONMENT; - delete process.env.TRUSTED_PROXY_HOPS; }); test('configured only when both halves are set', () => { @@ -68,30 +66,23 @@ test('sends the capture payload with the environment stamped', async () => { const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Firefox/131.0'; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; -// A browser request behind two load balancers (TRUSTED_PROXY_HOPS=2), with -// a forged entry the client put in front. -const browserHeaders = () => +// A browser request as it arrives through Cloudflare and the load balancer. +const browserHeaders = (country = 'GB') => new Headers({ 'user-agent': UA, - 'x-forwarded-for': '198.51.100.66, 203.0.113.9, 34.1.1.1, 35.2.2.2', + 'x-forwarded-for': '203.0.113.9, 34.1.1.1, 35.2.2.2', + 'cf-ipcountry': country, }); const visitors = [ - { - kind: 'consented', - who: { visitorId: 'v-123' }, - distinctId: 'v-123', - ip: '203.0.113.9', - personless: false, - }, + { kind: 'consented', who: { visitorId: 'v-123' }, distinctId: 'v-123', personless: false }, { kind: 'signed in without consent', who: { userId: 'u-789' }, distinctId: 'u-789', - ip: undefined, personless: false, }, - { kind: 'anonymous', who: {}, distinctId: UUID, ip: undefined, personless: true }, + { kind: 'anonymous', who: {}, distinctId: UUID, personless: true }, ] as const; for (const visitor of visitors) { @@ -123,14 +114,15 @@ for (const visitor of visitors) { } assert.equal(properties.$raw_user_agent, UA); - assert.equal(properties.$ip, visitor.ip); - assert.equal(properties.$geoip_disable, visitor.ip ? undefined : true); + assert.equal(properties.country, 'GB'); + assert.equal(properties.$ip, undefined); + assert.equal(properties.$geoip_disable, true); assert.equal(properties.$process_person_profile, visitor.personless ? false : undefined); assert.equal(properties.kind, 'chat'); }); } -test('an event with no browser request carries no user agent or address', async () => { +test('an event with no browser request carries no user agent or country', async () => { const captured: Captured[] = []; stubFetch(200, captured); @@ -138,10 +130,26 @@ test('an event with no browser request carries no user agent or address', async const properties = captured[0].body.properties as Record; assert.equal(properties.$raw_user_agent, undefined); - assert.equal(properties.$ip, undefined); + assert.equal(properties.country, null); assert.equal(properties.$geoip_disable, true); }); +for (const [header, country] of [ + ['us', 'US'], + ['XX', null], + ['T1', null], +] as const) { + test(`CF-IPCountry ${header} gives country ${country}`, async () => { + const captured: Captured[] = []; + stubFetch(200, captured); + + await sendEventToPosthog('embed.view', {}, { headers: browserHeaders(header) }); + + const properties = captured[0].body.properties as Record; + assert.equal(properties.country, country); + }); +} + test('a non-ok answer throws so track() can log it', async () => { const captured: Captured[] = []; stubFetch(500, captured); From 3923b0effd52d60ef715a3e41683ff2e2f67d833 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 15 Oct 2026 11:09:23 +0200 Subject: [PATCH 3/7] feat(search): send one usage event per search post, joined by trace id 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. --- docs/analytics.md | 41 ++- src/app/api/share/route.ts | 24 +- .../molecules/DataPrompt/DataPrompt.tsx | 9 +- .../organisms/DataSearch/DataSearch.tsx | 132 +++++---- .../organisms/DataSearch/SearchPanel.tsx | 6 +- .../DataSearch/useDatasetDownload.ts | 13 +- .../organisms/DataSearch/useSearchRun.ts | 273 ++++++++++++++---- src/lib/chat/shareMode.ts | 6 +- src/lib/chat/test/shareMode.test.ts | 11 +- src/lib/events/track.ts | 14 +- src/lib/search/searchClient.ts | 6 + src/lib/search/searchEvents.ts | 241 +++++++++++++++- src/lib/search/test/searchEvents.test.ts | 242 +++++++++++++++- 13 files changed, 878 insertions(+), 140 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index 5b4174b..c82c7c7 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -4,28 +4,57 @@ Usage events go to our own database, errors to Sentry, and page views to Google ## Usage events -The events DFO Overview asks for (Data access → "Events to record") land in the `event` table (`db/schema.ts`, migration `0006_events`): one row per event with a dotted `name`, the event's own `props` (JSONB), `user_id` when signed in, a random per-browser `visitor_id` (localStorage, no cookie) when the visitor has consented, and the `page`. No event row holds an IP address or the text of a Studio prompt. The three search events hold the first 500 characters of the query. +The events DFO Overview asks for (Data access → "Events to record") land in the `event` table (`db/schema.ts`, migration `0006_events`): one row per event with a dotted `name`, the event's own `props` (JSONB), `user_id` when signed in, a random per-browser `visitor_id` (localStorage, no cookie) when the visitor has consented, and the `page`. No event row holds an IP address or the text of a Studio prompt. The search events hold the first 500 characters of the query. The browser stores `visitor_id` only after the visitor accepts "DFO usage statistics" in the Analytics category of the cookie banner (Usercentrics service `L3eMCSMT3WELG7`, read through the CMP's `window.__ucCmp` API and `UC_CONSENT` event in `src/lib/consent.ts`). Until then, including while the banner is loading or blocked, events arrive with a null visitor id. Withdrawing consent deletes the stored id. | Event | When | Props | |---|---|---| -| `search.query` | a search settled with at least one resolved part, or a catalogue answer | `query` (its first 500 characters), `edited`, `kind` (`resolution` / `catalogue` / `declined`), `reason` (a declined answer's `out_of_scope` / `requires_analysis`, else null), `operations` (the operation verbs the question asked for), `resolved[]` (`specId`, `measure`, `filters` (each bound property's ref or refs; a set of more than three is `{sample, count}`), `entities` (per role: `group` (the named group's `dcid` and `label`, else null), `sample` (the first three member dcids), `count` (all members)), `window`, `facets`, `statVarCount`, `entityCount`, `sources` (source ids), `sourceCount`), `refused[]` (`variableText`, `status`, `reason`, `entityTerms` (the first three)), `truncated` (true when the event had to drop to its minimal form or floor to fit the cap) | -| `search.empty` | a search that answered nothing: no part resolved, or the question was declined | same shape, `resolved` empty — the data-gap signal | -| `search.studio_handoff` | a click on a Studio action on a search answer | same shape, plus `seeds` (the seeds handed to Studio, 0 when none could be stored) | +| `search.query` | a base run settled with at least one resolved part, or a catalogue answer | the run fields and settle fields below, `query` (its first 500 characters), `kind` (`resolution` / `catalogue` / `declined`), `reason` (a declined answer's `out_of_scope` / `requires_analysis`, else null), `operations` (the operation verbs the question asked for), `resolved[]` (`specId`, `measure`, `filters` (each bound property's ref or refs; a set of more than three is `{sample, count}`), `entities` (per role: `group` (the named group's `dcid` and `label`, else null), `sample` (the first three member dcids), `count` (all members)), `window`, `facets`, `statVarCount`, `entityCount`, `sources` (source ids), `sourceCount`), `refused[]` (`variableText`, `status`, `reason`, `entityTerms` (the first three)), `truncated` (true when the event had to drop to its minimal form or floor to fit the cap) | +| `search.empty` | a base run that answered nothing: no part resolved, or the question was declined | same shape, `resolved` empty; the data-gap signal, though a run with `timedOut` true reads as empty for parts the deadline cut | +| `search.edit` | an edit post settled: a change to an answer, re-resolved | same shape as `search.query`, with `edited` true and `editKind` | +| `search.error` | a post failed | the run fields, `query`, `error` (the problem-details `code`, else the HTTP status, else `stream` for a stream that broke or closed without an answer), `status` (the HTTP status, when there was one) | +| `search.abandon` | a post left in flight: the page was hidden, a newer search or edit replaced it, the reader opened the starting point, or the reader reset the page | the run fields, `reason` (`hidden` / `superseded` / `reset`), `step` (the last pipeline step reached, null on an edit post) | +| `search.start_point` | the starting point shown from the page's own catalogue, with no search call | `source` | +| `search.studio_handoff` | a click on a Studio action on a search answer | same shape, plus `seeds` (the seeds handed to Studio, 0 when none could be stored), `searchId` (absent on the starting point) and `traceId` | +| `search.action` | a visitor acted on the answer on screen | `searchId` and `traceId` (the search and the post whose answer is on screen, null on the starting point), `action`, and per action: `pick_reading` (a reading card's "Use this reading") with `partIndex` and `specId`; `download` (the side panel's download reached its file) with `partIndex`, `specId`, `format` and `bundle`; `share` (a share link minted, the first time the Share box shows a tab) with `mode` (`live` / `snapshot`) and `shareId` | | `studio.prompt` | a prompt sent in the Studio | `turn`, `intent` (`new` / `follow_up` / `edit`), `length`, `guest` | | `studio.card` | a card the harness put on the canvas | `kind`, `turn`, `source`, `indicators` (DCIDs), `years`, `unit` (in words with its magnitude and price basis, `US Dollar, millions, constant 2023 prices`; a composed unit as its numerator and denominator either side of "per", each part's magnitude and price basis in brackets beside it, `US Dollar per Total population` or `Euro (constant 2020 prices) per Total population`; a percentage as `Percent`; null when the card states none), `guest` | | `studio.session_end` | New Chat, or the page is left | `turns`, `cards`, `pins`, `guest` | | `auth.signin` | a provider callback ended in a session | `provider` | | `guest.wall_hit` | a guest used up the free prompts | `prompts` | | `feedback.submit` | a feedback submission | `kind` (`chat` / `widget`) | +| `share.create` | a share link stored | `shareId`, `kind` (`conversation` / `canvas` / `card`, or `search` for a search result), `mode` (`live` / `snapshot`). A search share joins its `search.action` through `shareId` | | `embed.view` | an `/embed/` page rendered | `shareId`, `kind` (`card` / `canvas` / `conversation`), `host` (the hostname of the page the iframe sits on, else null) | +Every search post, the base run and each edit after it, ends on one terminal event: `search.query`, `search.empty`, `search.edit`, `search.error`, or a `superseded` or `reset` abandon. A page closed mid-post leaves only its `hidden` abandon. `useSearchRun` gives each post a tracker from `searchPostTracker()` in `src/lib/search/searchEvents.ts`, which sends only the first terminal event it is asked for. These run fields are on every one of them: + +- `searchId`: a UUID minted when a search starts (a query, a permalink, a share or version link), kept by its edit posts. +- `post`: 0 for the base run, then 1, 2, … for each edit post of the same search. +- `source`: where the search came from: `typed`, `example` (a suggestion or example chip), `catalogue` (the starting point), `refine`, `retry`, `permalink` (`?q=`), `share` (`/s/`) or `version_link` (`?spec=`). +- `edited` (true from post 1) and `editKind` (`slot`, `entity`, `window`, `facet`, `pick`, `measure`, `parsed`, `shared`, or `replay` for a permalink's stored edits). +- `traceId`: the engine's trace id, the `trace_id` on the dfo-apis `resolve` log line, null when the post ended before the engine answered. +- `ms`: client time from the post's start to the event. +- `hidden`: true when the page was hidden while the post was in flight. + +A settled post adds `outcome` (a resolution's `status`, `definite` / `candidates` / `no_data` / `unsupported` / `mixed`, else `catalogue` or `declined`), `engineMs` (the engine's `done.elapsed_ms`), `byStep` (the engine's per-step timings) and `timedOut` (the engine's soft deadline cut the run short). + +A post sends `search.abandon` with reason `hidden` the first time the page is hidden while it is in flight. Background tabs keep streaming, so the post usually settles later, with `hidden: true`. A true abandon is a `hidden` abandon with no later terminal event for the same `(searchId, post)`: + +```sql +select a.props->>'searchId', a.props->>'post' from event a +where a.name = 'search.abandon' and a.props->>'reason' = 'hidden' + and not exists ( + select 1 from event t + where t.name in ('search.query', 'search.empty', 'search.edit', 'search.error') + and t.props->>'searchId' = a.props->>'searchId' and t.props->>'post' = a.props->>'post'); +``` + Browser code calls `track()` from `src/lib/events/client.ts`, which posts to `POST /api/events` (allow-listed names, props capped at 8 KB, always 204); server code calls `track()` from `src/lib/events/track.ts` directly. The list of names lives in `track.ts` — add there first. -When `POSTHOG_KEY` and `POSTHOG_HOST` are set, `track()` also forwards each event server-side to PostHog (EU cloud) through `sendEventToPosthog()` in `src/lib/events/posthog.ts`. The event keeps its props, minus any key starting with `$`, and adds `environment`, `page`, `signed_in` and `country`. Its `distinct_id` is the visitor id for a consented visitor, else the account id for a signed-in one. An event with neither gets a fresh random UUID and `$process_person_profile: false`, so PostHog creates no person for it. Each event carries the browser request's `User-Agent` as `$raw_user_agent`, which PostHog's bot filter reads. `country` is the two-letter code Cloudflare puts in the `CF-IPCountry` header, or null when Cloudflare has no location (`XX`) or the visitor uses Tor (`T1`). Every event sets `$geoip_disable: true`, because PostHog would otherwise place it at the Cloud Run server, and no event carries the visitor's IP. All five server callers pass the browser request's headers: `POST /api/events`, feedback, Studio shares, embed renders and the sign-in callback. An event with no request behind it passes `headers: null` and carries no user agent and a null `country`. +When `POSTHOG_KEY` and `POSTHOG_HOST` are set, `track()` also forwards each event server-side to PostHog (EU cloud) through `sendEventToPosthog()` in `src/lib/events/posthog.ts`. The event keeps its props, minus any key starting with `$`, and adds `environment`, `page`, `signed_in` and `country`. Its `distinct_id` is the visitor id for a consented visitor, else the account id for a signed-in one. An event with neither gets a fresh random UUID and `$process_person_profile: false`, so PostHog creates no person for it. Each event carries the browser request's `User-Agent` as `$raw_user_agent`, which PostHog's bot filter reads. `country` is the two-letter code Cloudflare puts in the `CF-IPCountry` header, or null when Cloudflare has no location (`XX`) or the visitor uses Tor (`T1`). Every event sets `$geoip_disable: true`, because PostHog would otherwise place it at the Cloud Run server, and no event carries the visitor's IP. All five server callers pass the browser request's headers: `POST /api/events`, feedback, shares (Studio and search), embed renders and the sign-in callback. An event with no request behind it passes `headers: null` and carries no user agent and a null `country`. -A resolved part records what the question asked for, and counts the StatVars and places it expanded to: an aid query expands to hundreds of each, which would pass the 8 KB cap. A role asked for as a named group, such as the European Union, records the group, three of its members and the member count. A set-valued filter with more than three refs records three and the count. `sources` lists every source id while the whole event fits the cap, and the first three per part when it does not; `sourceCount` is always the full number, so a list shorter than the count is a sample. If the event still exceeds the cap, each resolved part keeps only `specId`, `measure`, `statVarCount`, `entityCount`, `sourceCount` and the per-role place `count`, and the event carries `truncated: true`. If that form is still too large, the event ends on a fixed-size floor: an empty `query`, no refused parts, and per resolved part only a clipped `specId` and the integer counts. The ladder runs on the complete props, `seeds` included, and measures the JSON length as the route does; the API's limit of six parts bounds the floor. +A resolved part records what the question asked for, and counts the StatVars and places it expanded to: an aid query expands to hundreds of each, which would pass the 8 KB cap. A role asked for as a named group, such as the European Union, records the group, three of its members and the member count. A set-valued filter with more than three refs records three and the count. `sources` lists every source id while the whole event fits the cap, and the first three per part when it does not; `sourceCount` is always the full number, so a list shorter than the count is a sample. The run and settle fields stay on every form, and the floor drops only `byStep`. If the event still exceeds the cap, each resolved part keeps only `specId`, `measure`, `statVarCount`, `entityCount`, `sourceCount` and the per-role place `count`, and the event carries `truncated: true`. If that form is still too large, the event ends on a fixed-size floor: an empty `query`, no refused parts, and per resolved part only a clipped `specId` and the integer counts. The ladder runs on the complete props, `seeds` included, and measures the JSON length as the route does; the API's limit of six parts bounds the floor. The table is meant to be exported to BigQuery alongside the API usage log lines; until then it is plain SQL, e.g. the most-requested measures: diff --git a/src/app/api/share/route.ts b/src/app/api/share/route.ts index f217c25..3b99836 100644 --- a/src/app/api/share/route.ts +++ b/src/app/api/share/route.ts @@ -12,7 +12,7 @@ import { readSearchShareRequest } from '@/lib/search/searchShareRequest'; // A search result's link: dataset references only, no numbers. The search // page is public, so an account is optional here (the share is simply // unowned without one). -const shareSearch = async (body: any) => { +const shareSearch = async (body: any, headers: Headers) => { const request = readSearchShareRequest(body); if (!request) { @@ -21,9 +21,14 @@ const shareSearch = async (body: any) => { const user = await getUser(); + let created: { id: string }; + try { - return NextResponse.json( - await createSearchShare(user?.id ?? null, request.mode, request.query, request.parts), + created = await createSearchShare( + user?.id ?? null, + request.mode, + request.query, + request.parts, ); } catch (error) { // The platform refused the register/mint — say which, don't store a @@ -39,6 +44,17 @@ const shareSearch = async (body: any) => { return NextResponse.json({ error: 'platform_refused', detail: message }, { status: 502 }); } + + // Recorded after the response, so the link does not wait on analytics. + after(() => + track( + 'share.create', + shareCreateProps({ id: created.id, kind: 'search', mode: request.mode }), + { userId: user?.id ?? null, headers }, + ), + ); + + return NextResponse.json(created); }; // POST /api/share { kind, conversation, cardId?, mode? } → { id, title } @@ -51,7 +67,7 @@ export const POST = async (request: Request) => { const body = await request.json().catch(() => null); if (body?.kind === 'search') { - return shareSearch(body); + return shareSearch(body, request.headers); } const user = await getUser(); diff --git a/src/components/molecules/DataPrompt/DataPrompt.tsx b/src/components/molecules/DataPrompt/DataPrompt.tsx index 9dee34b..d2a91e7 100644 --- a/src/components/molecules/DataPrompt/DataPrompt.tsx +++ b/src/components/molecules/DataPrompt/DataPrompt.tsx @@ -11,7 +11,8 @@ import { revealIndex } from '@/hooks/useReveal'; export type DataPromptProps = { suggestions?: string[]; suggestionsHidden?: boolean; - onSubmit?: (query: string) => void; + // `via` says whether the text was typed or a suggestion chip was picked. + onSubmit?: (query: string, via: 'typed' | 'example') => void; onChange?: (query: string) => void; // Take the caret on mount. Applied from an effect rather than the DOM // attribute, so the decision can depend on the device without the server @@ -88,9 +89,9 @@ const DataPrompt = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const submit = (query: string) => { + const submit = (query: string, via: 'typed' | 'example' = 'typed') => { if (query.trim()) { - onSubmit?.(query.trim()); + onSubmit?.(query.trim(), via); } }; @@ -136,7 +137,7 @@ const DataPrompt = ({ {suggestions && suggestions.length > 0 && ( submit(text, 'example')} hidden={suggestionsHidden} className={styles.try} /> diff --git a/src/components/organisms/DataSearch/DataSearch.tsx b/src/components/organisms/DataSearch/DataSearch.tsx index 27dd7ec..36c027c 100644 --- a/src/components/organisms/DataSearch/DataSearch.tsx +++ b/src/components/organisms/DataSearch/DataSearch.tsx @@ -22,6 +22,7 @@ import { type RefineOpen, } from './SearchSentence'; import SearchTable from './SearchTable'; +import type { DatasetDownloaded } from './useDatasetDownload'; import { useSearchRun } from './useSearchRun'; import Button from '@/components/atoms/Button/Button'; @@ -44,6 +45,7 @@ import { fetchVersionLink, type OpenedSearchShare, resolveBuffered, + type SearchShareMode, type VersionLinkResult, } from '@/lib/search/searchClient'; import { searchSharePart } from '@/lib/search/searchShareRequest'; @@ -54,7 +56,7 @@ import { snapshotLiveNote, studioHandoffParts, } from '@/lib/search/resultNotes'; -import { searchEventName, searchEventProps } from '@/lib/search/searchEvents'; +import { searchEventProps, type SearchSource } from '@/lib/search/searchEvents'; import { earlySentenceTokens, tokenPart } from '@/lib/search/sentencePlan'; import { handoffEdited, @@ -111,6 +113,12 @@ const EarlySentenceHeading = ({ // `catalogue` is the search catalogue the page loaded on the server, or null // when the API serves none: the idle chips, the stat tiles and the starting // point read it. +/** What a visitor did with a search answer, sent as `search.action`. */ +type SearchAction = + | { action: 'pick_reading'; partIndex: number; specId: string } + | ({ action: 'download'; partIndex: number } & DatasetDownloaded) + | { action: 'share'; mode: SearchShareMode; shareId: string }; + const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { const router = useRouter(); const searchParams = useSearchParams(); @@ -152,6 +160,7 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { editParsed, editShared, editFacet, + searchIds, } = useSearchRun(); // A version link runs with no question of its own: the resolved sentence // stands in for it wherever the page needs text (the Studio handoff, a @@ -164,13 +173,15 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { const [refineOpen, setRefineOpen] = useState(null); // A query as text: the starting point's own question shows the catalogue - // the page holds, with no search call; any other text is a search. + // the page holds, with no search call, and records `search.start_point`. + // Any other text is a search, whose events useSearchRun sends. const runQuery = useCallback( - (text: string) => { + (text: string, source: SearchSource) => { if (text === STARTING_POINT_QUERY) { showStartingPoint(text); + track('search.start_point', { source }); } else { - run({ kind: 'raw_text', query: text }, text); + run({ kind: 'raw_text', query: text }, text, source); } }, [run, showStartingPoint], @@ -236,16 +247,16 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { }, []); // Opens a share, or a link read as one, and keeps it as the view's // origin until the user moves on from it. - const enterShare = useCallback((shared: OpenedSearchShare) => { + const enterShare = useCallback((shared: OpenedSearchShare, source: SearchSource) => { openedShareRef.current = shared; setOpenedShare(shared); - openShareRef.current(shared); + openShareRef.current(shared, source); }, []); const submitQuery = useCallback( - (text: string) => { + (text: string, source: SearchSource) => { leaveShare(); - runQuery(text); + runQuery(text, source); }, [runQuery, leaveShare], ); @@ -255,9 +266,9 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { // empty sentence and drop the link. const retry = useCallback(() => { if (openedShareRef.current) { - enterShare(openedShareRef.current); + enterShare(openedShareRef.current, 'retry'); } else { - submitQuery(query); + submitQuery(query, 'retry'); } }, [enterShare, submitQuery, query]); @@ -279,6 +290,7 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { }); const seeds = handoffSeeds(parts); const token = writeSeeds(() => window.sessionStorage, seeds); + const { searchId, traceId } = searchIds(); track( 'search.studio_handoff', @@ -289,10 +301,27 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { reason: answerReason, parts, seeds: token ? seeds.length : 0, + run: { ...(searchId && { searchId }), traceId }, }), ); router.push(studioHref(question, token)); - }, [router, query, edits.length, openedShare, response, parts, answerKind, answerReason]); + }, [ + router, + query, + edits.length, + openedShare, + response, + parts, + answerKind, + answerReason, + searchIds, + ]); + + // A visitor's action on the answer on screen (docs/analytics.md). + const trackAction = useCallback( + (action: SearchAction) => track('search.action', { ...searchIds(), ...action }), + [searchIds], + ); // Live suggestions: a debounced background resolve (no observations — faster) // renders interpretation cards under the prompt while the user is still typing. @@ -378,7 +407,7 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { return; } - enterShare(shared); + enterShare(shared, 'share'); }); // `openShare` is deliberately absent for the same reason `run` and // `replay` are off the permalink effect's array below: watching a @@ -417,7 +446,7 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { } if (result.kind === 'opened') { - enterShare(result.shared); + enterShare(result.shared, 'version_link'); } else { setLinkRefusal({ link, result }); } @@ -437,7 +466,7 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { if (shared) { startedRef.current = `spec:${href}`; - enterShare({ ...shared, mode: 'live', link: latest }); + enterShare({ ...shared, mode: 'live', link: latest }, 'version_link'); } window.history.replaceState(null, '', href); @@ -458,7 +487,7 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { startedRef.current = key; if (!s) { - runRef.current(q); + runRef.current(q, 'permalink'); return; } @@ -479,9 +508,9 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { // URL-sync effect below must not read that transient empty // state as "no edits" and strip `s=` before they land. suppressEmptyEditWriteRef.current = true; - replayRef.current(state); + replayRef.current(state, 'permalink'); } else { - runRef.current(q); + runRef.current(q, 'permalink'); } }); // `run` and `replay` are deliberately absent. Watching either would @@ -579,34 +608,6 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { }; }, [phase, query, edits, parts, answer]); - // Usage event, once per query, when the run has settled: every part for - // a resolution, the run itself for a catalogue or declined answer, which - // has no parts. `search.empty` when nothing answered (searchEvents.ts). - const trackedQueryRef = useRef(null); - - useEffect(() => { - const settled = - phase === 'done' && - (answer !== null || - (parts.length > 0 && parts.every((part) => part.phase !== 'loading'))); - - if (!settled || !query || trackedQueryRef.current === query) { - return; - } - - trackedQueryRef.current = query; - - const props = searchEventProps({ - query, - edited: edits.length > 0, - kind: answerKind, - reason: answerReason, - parts, - }); - - track(searchEventName(props), props); - }, [phase, answer, answerKind, answerReason, parts, query, edits.length]); - // Read, not watched: startNew's identity changes with reset(), and // watching it would re-run the effect below on internal state. const startNewRef = useRef<() => void>(() => {}); @@ -758,7 +759,15 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { mode, query, parts: sharedParts.map(searchSharePart), - }).then((result) => ('id' in result ? { ...result, datasets: sharedDatasets } : result)); + }).then((result) => { + if (!('id' in result)) { + return result; + } + + trackAction({ action: 'share', mode, shareId: result.id }); + + return { ...result, datasets: sharedDatasets }; + }); // A part whose question asked for more than search does carries an // operation_not_applied departure, whose action hands it to Studio; // without one, the side panel offers Studio itself, so a page shows one @@ -936,7 +945,10 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { } leaveShare(); - openShare({ ...openedShare, mode: 'live' }); + openShare( + { ...openedShare, mode: 'live' }, + 'share', + ); }} > View latest @@ -973,9 +985,11 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { submitQuery(text, 'example')} onStartNew={startNew} - onShowCatalogue={() => submitQuery(STARTING_POINT_QUERY)} + onShowCatalogue={() => + submitQuery(STARTING_POINT_QUERY, 'catalogue') + } onStudio={openStudio} /> @@ -1022,7 +1036,7 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { onEditFacet={editFacet} onEditParsed={editParsed} onEditShared={editShared} - onRefine={submitQuery} + onRefine={(text) => submitQuery(text, 'refine')} onStudio={openStudio} refine={refine} onRefineOpen={setRefineOpen} @@ -1073,6 +1087,13 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { note={downloadNote(definiteParts[0].part?.warnings)} createShare={createShare} onStudio={studioDeparture ? undefined : openStudio} + onDownload={(downloaded) => + trackAction({ + action: 'download', + partIndex: definiteParts[0].index, + ...downloaded, + }) + } /> )} @@ -1099,8 +1120,15 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { key={part.index} part={part.part} prefix={parts.length > 1 ? part.variableText : null} - onPick={(spec) => pickCandidate(part.index, spec)} - onRetry={() => submitQuery(query)} + onPick={(spec) => { + trackAction({ + action: 'pick_reading', + partIndex: part.index, + specId: spec.spec_id, + }); + pickCandidate(part.index, spec); + }} + onRetry={() => submitQuery(query, 'retry')} /> ), )} diff --git a/src/components/organisms/DataSearch/SearchPanel.tsx b/src/components/organisms/DataSearch/SearchPanel.tsx index 9d43fd3..271f96f 100644 --- a/src/components/organisms/DataSearch/SearchPanel.tsx +++ b/src/components/organisms/DataSearch/SearchPanel.tsx @@ -7,7 +7,7 @@ import { useEffect, useRef, useState } from 'react'; import DatasetDownload from './DatasetDownload'; import styles from './DataSearch.module.scss'; import SearchShare, { type CreateSearchShare } from './SearchShare'; -import { useDatasetDownload } from './useDatasetDownload'; +import { type DatasetDownloaded, useDatasetDownload } from './useDatasetDownload'; import Button from '@/components/atoms/Button/Button'; import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; @@ -22,6 +22,7 @@ const SearchPanel = ({ note, createShare, onStudio, + onDownload, }: { spec: any; // Says a download may differ from the preview (resultNotes downloadNote). @@ -32,6 +33,8 @@ const SearchPanel = ({ // hidden without it, as it is while an operation_not_applied departure // offers Studio. onStudio?: () => void; + // Records a download that reached its file. + onDownload?: (downloaded: DatasetDownloaded) => void; }) => { const [detailsOpen, setDetailsOpen] = useState(false); // The share box pops like the other menus here: 'closed' keeps it mounted @@ -71,6 +74,7 @@ const SearchPanel = ({ specId: spec?.spec_id, definition: spec?.definition, entities: spec?.entities, + onDownload, }); const rowCount = downloadRowCount(spec); diff --git a/src/components/organisms/DataSearch/useDatasetDownload.ts b/src/components/organisms/DataSearch/useDatasetDownload.ts index 4516bda..2c395c1 100644 --- a/src/components/organisms/DataSearch/useDatasetDownload.ts +++ b/src/components/organisms/DataSearch/useDatasetDownload.ts @@ -98,15 +98,25 @@ export type DatasetDownloadTarget = { // The answer's bound entities, whose type heads the observationAbout // group of extra columns ("Country"). entities?: { role?: string; entity_type?: string | null }[] | null; + // Called once a download's file opens, with what was rendered. + onDownload?: (downloaded: DatasetDownloaded) => void; }; +/** A download that reached its file: the spec and the choices it used. */ +export type DatasetDownloaded = { specId: string; format: DatasetFormat; bundle: boolean }; + /** * The state behind the Datasets API download sheet: format, bundle and * enrichment choices (remembered across answers), the dataset's own options * document, and the mint → manifest → signed URL download. Search's panel * uses it, through DatasetDownload. */ -export const useDatasetDownload = ({ specId, definition, entities }: DatasetDownloadTarget) => { +export const useDatasetDownload = ({ + specId, + definition, + entities, + onDownload, +}: DatasetDownloadTarget) => { const choices = useSyncExternalStore(subscribeChoices, readChoices, serverChoices); const [optionsOpen, setOptionsOpen] = useState(false); const [downloading, setDownloading] = useState(false); @@ -308,6 +318,7 @@ export const useDatasetDownload = ({ specId, definition, entities }: DatasetDown } window.location.assign(artifactHref(artifact.url)); + onDownload?.({ specId, format, bundle }); } catch (caught) { if (controller.signal.aborted) { return; diff --git a/src/components/organisms/DataSearch/useSearchRun.ts b/src/components/organisms/DataSearch/useSearchRun.ts index 0905c49..aabe335 100644 --- a/src/components/organisms/DataSearch/useSearchRun.ts +++ b/src/components/organisms/DataSearch/useSearchRun.ts @@ -74,8 +74,8 @@ * edit post waits out the whole grace for an event that isn't coming. * - edits: every part's latest successful identity/parsed entry, ready * for permalink.ts's encodePermalinkState. - * - `run(input, label)`: a fresh raw_text query; full replace, aborts any - * edit post in flight too. + * - `run(input, label, source)`: a fresh raw_text query; full replace, + * aborts any edit post in flight too. * - `showStartingPoint(label)`: settles on a catalogue answer with no place * and no search call, for the page to fill from the catalogue it holds. * - `reset()`: abort everything, back to idle. @@ -85,7 +85,7 @@ * not the part's current one. Every entry carries its part's operations * and the named groups of the Spec its identity derives from (see * datasetEntries.ts). - * - `replay(state)`: runs `state.query`, then, right after its `response` + * - `replay(state, source)`: runs `state.query`, then, right after its `response` * event, builds one entries list from the fresh parts with every stored * edit applied as an override at its own index (skipped with a note on * that part when its `variableText` no longer matches), and posts it once, @@ -123,9 +123,24 @@ * A refusal from the door carries a top-level `pointer` naming the entry it * refused (`/input/entries/{i}/...`). `editError` keys to that index when * present, or to every part the failed post was touching when it isn't. + * + * Usage events (docs/analytics.md). Every run start (`run`, `replay`, + * `openShare`) mints a `searchId`. Each post, the base run as post 0 and each + * edit post after it, owns a `searchPostTracker` made when the post starts. + * The post's own stream ends it from its own terminal path: `done` settles, + * an `error` event or a thrown error fails, and a stream that closes with + * neither fails as `stream`. A post aborted by a newer one or by + * `showStartingPoint` abandons as `superseded` at the abort site, and one + * aborted by `reset` abandons as `reset`. The tracker sends only the first of + * these, so each post sends one terminal event. A page hidden mid-post sends a `hidden` + * abandon from the visibility listener below. `searchIds()` reads the + * current search's id and the trace of the answer on screen, for the events + * a visitor's click on that answer sends. */ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { track } from '@/lib/events/client'; import { withEntityEdit, withFacetEdit, withResolvedSlotEdit } from '@/lib/search/promote'; import type { PermalinkEdit, PermalinkState } from '@/lib/search/permalink'; @@ -186,6 +201,12 @@ import type { Spec, SpecObservations, } from '@/lib/search/types'; +import { + searchPostTracker, + type EditKind, + type SearchPostTracker, + type SearchSource, +} from '@/lib/search/searchEvents'; import { sharedUnitEdit } from '@/lib/search/unitPicker'; import type { WindowApplyValue } from '@/lib/search/windowState'; @@ -266,6 +287,24 @@ function partsFromResponse(response: Resolution): HydratedPart[] { })); } +/** Ends a post's usage tracker on its own `done`: the event describes the + * post's own answer with the late warnings and departures applied, read + * from the stream's closure because React state may already hold a newer + * post's answer. */ +function settlePost( + tracker: SearchPostTracker, + landed: ResolveResponse | null, + done: Extract, +) { + const settled = landed?.kind === 'resolution' ? applyDoneEvent(landed, done) : landed; + + tracker.settle({ + response: settled, + parts: settled?.kind === 'resolution' ? partsFromResponse(settled) : [], + done, + }); +} + /** Maps a refusal's `/input/entries/{i}/...` pointer to the entry index it * names. Null when the pointer is absent, a different shape, or out of * range for this post's own entries. The caller falls back to marking @@ -351,6 +390,19 @@ export const useSearchRun = () => { // reproduces. const basePhraseByPartRef = useRef<(string | null)[]>([]); const opSeq = useRef(0); + // The current search's id and source, the last post number used, and the + // trace of the post whose answer is on screen. Minted by every run start, + // cleared by reset. + const searchRef = useRef<{ + id: string; + source: SearchSource; + posts: number; + traceId: string | null; + } | null>(null); + // The base run's and the current edit post's trackers. An ended tracker + // ignores every call, so neither ref needs clearing when its post ends. + const baseTrackRef = useRef(null); + const editTrackRef = useRef(null); // A monotonic stamp for every place_options merge, regardless of spec or // scope -- PlaceOptionsEntry.arrival, read by view.ts's // placeOptionsSignature so PlaceEditor's effect can tell a fresh arrival @@ -359,6 +411,54 @@ export const useSearchRun = () => { // scope already landed). const placeOptionsArrivalRef = useRef(0); + // A tracker for the next post of the current search: post 0 for a base + // run, the next number for an edit. Every run start sets `searchRef` + // first, and an edit needs a run's parts, so the fallback never mints. + const trackPost = useCallback((editKind: EditKind | null, query: string) => { + const search = (searchRef.current ??= { + id: crypto.randomUUID(), + source: 'typed', + posts: 0, + traceId: null, + }); + + return searchPostTracker( + { + searchId: search.id, + post: editKind ? ++search.posts : 0, + source: search.source, + editKind, + query, + }, + { emit: track, now: () => performance.now() }, + ); + }, []); + + // The first hide of the page while a post is in flight. Browsers send + // `visibilitychange` before `pagehide`, so this one listener covers both. + useEffect(() => { + const onVisibility = () => { + if (document.visibilityState !== 'hidden') return; + baseTrackRef.current?.hide(); + editTrackRef.current?.hide(); + }; + + document.addEventListener('visibilitychange', onVisibility); + + return () => document.removeEventListener('visibilitychange', onVisibility); + }, []); + + // The search and the trace behind the answer on screen, read when a + // visitor acts on it (`search.action`, the Studio hand-off). Both are + // null before a search and on the starting point. + const searchIds = useCallback( + () => ({ + searchId: searchRef.current?.id ?? null, + traceId: searchRef.current?.traceId ?? null, + }), + [], + ); + const updateRuntime = useCallback( (index: number, fn: (runtime: PartRuntime) => PartRuntime) => { setRuntimes((prev) => { @@ -497,9 +597,12 @@ export const useSearchRun = () => { op: number, controller: AbortController, input: Extract, + tracker: SearchPostTracker, onResponse?: (normalized: Resolution) => void, ) => { const isCurrent = () => runOp.current === op; + // This post's own answer, for its usage event (`settlePost`). + let landed: ResolveResponse | null = null; resolveStream( buildRequest(input, true), @@ -518,6 +621,7 @@ export const useSearchRun = () => { break; case 'step': if (!event.ran) break; + tracker.step(event.step); if (event.variable_index == null) { setSharedSteps((prev) => addStep(prev, event.step)); } else { @@ -544,6 +648,11 @@ export const useSearchRun = () => { break; case 'response': { const normalized = event.response as ResolveResponse; + landed = normalized; + tracker.respond(normalized); + if (searchRef.current) { + searchRef.current.traceId = normalized.diagnostics.trace_id; + } // A catalogue or declined answer has no parts: // the run settles on its own `done` with no // skeleton to size and nothing to replay. @@ -580,6 +689,7 @@ export const useSearchRun = () => { // into the part they name. setResponse((prev) => (prev ? applyDoneEvent(prev, event) : prev)); setPhase('done'); + settlePost(tracker, landed, event); // An edit post already in flight (fired between // this run's own `response` and `done`, exactly // what permalink replay does) owns the final @@ -599,15 +709,24 @@ export const useSearchRun = () => { case 'error': setError(event.detail); setPhase('error'); + tracker.fail({ + code: event.code, + status: event.status, + traceId: event.trace_id, + }); break; } }, controller.signal, - ).catch((err: Error) => { - if (!isCurrent() || err.name === 'AbortError') return; - setError(err.message); - setPhase('error'); - }); + ) + // A stream that closed with no `done` or `error`. + .then(() => tracker.fail({ code: 'stream' })) + .catch((err: Error) => { + if (!isCurrent() || err.name === 'AbortError') return; + setError(err.message); + setPhase('error'); + tracker.fail({ code: 'stream' }); + }); }, [applyTrailingEvent], ); @@ -616,10 +735,13 @@ export const useSearchRun = () => { ( input: Extract, label: string, + source: SearchSource, onResponse?: (normalized: Resolution) => void, ) => { // A fresh query replaces everything: abort the previous full run // and any edit post in flight. + baseTrackRef.current?.abandon('superseded'); + editTrackRef.current?.abandon('superseded'); runController.current?.abort(); postController.current?.controller.abort(); postController.current = null; @@ -633,6 +755,11 @@ export const useSearchRun = () => { runController.current = controller; runOp.current = op; + searchRef.current = { id: crypto.randomUUID(), source, posts: 0, traceId: null }; + + const tracker = trackPost(null, label); + + baseTrackRef.current = tracker; setPhase('loading'); setQuery(label); @@ -655,22 +782,29 @@ export const useSearchRun = () => { setPendingSnapshotSpecs(new Set()); setUnreadableRefs(new Set()); - runBaseStream(op, controller, input, onResponse); + runBaseStream(op, controller, input, tracker, onResponse); }, - [runBaseStream], + [runBaseStream, trackPost], ); /** A fresh raw_text query. Full replace: every part and any edit post in * flight is gone the moment this starts. */ const run = useCallback( - (input: Extract, label: string) => { - startRun(input, label); + ( + input: Extract, + label: string, + source: SearchSource, + ) => { + startRun(input, label, source); }, [startRun], ); /** Abort anything in flight and drop back to a pristine idle run state. */ const reset = useCallback(() => { + baseTrackRef.current?.abandon('reset'); + editTrackRef.current?.abandon('reset'); + searchRef.current = null; runController.current?.abort(); postController.current?.controller.abort(); postController.current = null; @@ -706,6 +840,11 @@ export const useSearchRun = () => { * with no place, under `label`. */ const showStartingPoint = useCallback( (label: string) => { + // The reader moved to another view: end any post in flight as + // superseded before reset() would send `reset`. The tracker + // sends only the first terminal event. + baseTrackRef.current?.abandon('superseded'); + editTrackRef.current?.abandon('superseded'); reset(); setPhase('done'); setQuery(label); @@ -722,8 +861,11 @@ export const useSearchRun = () => { basisParts: HydratedPart[], touchedIndices: number[], rawQuery: string, + tracker: SearchPostTracker, ) => { const isCurrent = () => postController.current?.op === op; + // This post's own answer, for its usage event. + let landed: Resolution | null = null; // Settling clears the pending-override queue only when nothing // has superseded this post: a superseded post's settle (its own // abort's catch) must never clobber what the superseding post @@ -771,6 +913,7 @@ export const useSearchRun = () => { // ever answers with a resolution. if (normalized.kind !== 'resolution') { fail(`Unexpected ${normalized.kind} answer to an edit.`, null); + tracker.fail({ code: 'unexpected_answer' }); break; } const restored = restorePlaceholderParts( @@ -779,6 +922,11 @@ export const useSearchRun = () => { entries.length, ); + landed = restored; + tracker.respond(restored); + if (searchRef.current) { + searchRef.current.traceId = restored.diagnostics.trace_id; + } setResponse(restored); setExpectedParts( restored.parts.map((part, i) => ({ @@ -803,10 +951,16 @@ export const useSearchRun = () => { // streams the same event shape. setResponse((prev) => (prev ? applyDoneEvent(prev, event) : prev)); settle(); + settlePost(tracker, landed, event); break; case 'error': fail(event.detail, event.pointer); settle(); + tracker.fail({ + code: event.code, + status: event.status, + traceId: event.trace_id, + }); break; // interpretation/step/shape/sentence never arrive on // this door. @@ -815,12 +969,16 @@ export const useSearchRun = () => { } }, controller.signal, - ).catch((err: Error & { pointer?: string | null }) => { - if (!isCurrent()) return; - settle(); - if (err.name === 'AbortError') return; - fail(err.message, err.pointer); - }); + ) + // A stream that closed with no `done` or `error`. + .then(() => tracker.fail({ code: 'stream' })) + .catch((err: Error & { pointer?: string | null }) => { + if (!isCurrent()) return; + settle(); + if (err.name === 'AbortError') return; + fail(err.message, err.pointer); + tracker.fail({ code: 'stream' }); + }); }, [applyTrailingEvent, recordEdits], ); @@ -837,6 +995,7 @@ export const useSearchRun = () => { ( override: DatasetEntriesOverride | DatasetEntriesOverride[], touchedIndices: number[], + editKind: EditKind, basisParts: HydratedPart[] = parts, // `replay` passes `state.query` explicitly: it fires synchronously // inside the base run's own `response` handler, before React has @@ -860,12 +1019,17 @@ export const useSearchRun = () => { pendingOverridesRef.current = overrides; pendingIndicesRef.current = indices; + editTrackRef.current?.abandon('superseded'); postController.current?.controller.abort(); const controller = new AbortController(); const op = ++opSeq.current; postController.current = { op, controller }; setEditPostInFlight(true); + const tracker = trackPost(editKind, rawQuery); + + editTrackRef.current = tracker; + setRuntimes((prev) => prev.map((r, i) => ({ ...r, @@ -878,9 +1042,9 @@ export const useSearchRun = () => { })), ); - runDatasetStream(op, controller, entries, basisParts, [...indices], rawQuery); + runDatasetStream(op, controller, entries, basisParts, [...indices], rawQuery, tracker); }, - [parts, query, runDatasetStream], + [parts, query, runDatasetStream, trackPost], ); const identityOverrideEntry = useCallback( @@ -907,7 +1071,7 @@ export const useSearchRun = () => { const identity = withResolvedSlotEdit(fromSpec, slotIndex, binding, entityRole); const entry = identityOverrideEntry(partIndex, fromSpec, identity); - runDatasetEdit({ index: partIndex, entry }, [partIndex]); + runDatasetEdit({ index: partIndex, entry }, [partIndex], 'slot'); }, [identityOverrideEntry, runDatasetEdit], ); @@ -919,7 +1083,7 @@ export const useSearchRun = () => { const identity = withEntityEdit(fromSpec.identity as BaseIdentity, role, dcids); const entry = identityOverrideEntry(partIndex, fromSpec, identity); - runDatasetEdit({ index: partIndex, entry }, [partIndex]); + runDatasetEdit({ index: partIndex, entry }, [partIndex], 'entity'); }, [identityOverrideEntry, runDatasetEdit], ); @@ -934,7 +1098,7 @@ export const useSearchRun = () => { }; const entry = identityOverrideEntry(partIndex, fromSpec, identity); - runDatasetEdit({ index: partIndex, entry }, [partIndex]); + runDatasetEdit({ index: partIndex, entry }, [partIndex], 'window'); }, [identityOverrideEntry, runDatasetEdit], ); @@ -966,7 +1130,7 @@ export const useSearchRun = () => { axis: A, binding: IdentityFacets[A] | null, ) => { - runDatasetEdit(facetOverride(partIndex, fromSpec, axis, binding), [partIndex]); + runDatasetEdit(facetOverride(partIndex, fromSpec, axis, binding), [partIndex], 'facet'); }, [facetOverride, runDatasetEdit], ); @@ -976,7 +1140,7 @@ export const useSearchRun = () => { (partIndex: number, candidate: any) => { const entry = identityOverrideEntry(partIndex, candidate, candidate.identity); - runDatasetEdit({ index: partIndex, entry }, [partIndex]); + runDatasetEdit({ index: partIndex, entry }, [partIndex], 'pick'); }, [identityOverrideEntry, runDatasetEdit], ); @@ -995,6 +1159,7 @@ export const useSearchRun = () => { ), })), picks.map(({ partIndex }) => partIndex), + 'measure', ); }, [parts, runDatasetEdit], @@ -1004,9 +1169,11 @@ export const useSearchRun = () => { // separate top-level echo the way the other three entry kinds do. const editParsed = useCallback( (partIndex: number, part: ParsedPart) => { - runDatasetEdit({ index: partIndex, entry: { entry_kind: 'parsed', part } }, [ - partIndex, - ]); + runDatasetEdit( + { index: partIndex, entry: { entry_kind: 'parsed', part } }, + [partIndex], + 'parsed', + ); }, [runDatasetEdit], ); @@ -1019,25 +1186,30 @@ export const useSearchRun = () => { // whose part no longer asks the same variable is skipped with a // one-line note on that block instead. const replay = useCallback( - (state: PermalinkState) => { - startRun({ kind: 'raw_text', query: state.query }, state.query, (normalized) => { - const basisParts = partsFromResponse(normalized); - const { overrides, touched, stale } = buildReplayPlan( - state.edits, - normalized.parts, - ); - - for (const index of stale) { - updateRuntime(index, (r) => ({ - ...r, - editError: 'This edit no longer applies to the current query.', - })); - } + (state: PermalinkState, source: SearchSource) => { + startRun( + { kind: 'raw_text', query: state.query }, + state.query, + source, + (normalized) => { + const basisParts = partsFromResponse(normalized); + const { overrides, touched, stale } = buildReplayPlan( + state.edits, + normalized.parts, + ); + + for (const index of stale) { + updateRuntime(index, (r) => ({ + ...r, + editError: 'This edit no longer applies to the current query.', + })); + } - if (overrides.length === 0) return; + if (overrides.length === 0) return; - runDatasetEdit(overrides, touched, basisParts, state.query); - }); + runDatasetEdit(overrides, touched, 'replay', basisParts, state.query); + }, + ); }, [startRun, updateRuntime, runDatasetEdit], ); @@ -1048,7 +1220,7 @@ export const useSearchRun = () => { // A snapshot link also reads each part's frozen observations back from // dfo-apis and lays them over the live observations. const openShare = useCallback( - (shared: OpenedSearchShare) => { + (shared: OpenedSearchShare, source: SearchSource) => { const entries: DatasetResubmitEntry[] = shared.refs.map(sharedRefEntry); // The replay's part `i` answers ref `i`. A snapshot reads its @@ -1062,6 +1234,7 @@ export const useSearchRun = () => { ...(shared.query ? { raw_query: shared.query } : {}), }, shared.query, + source, shared.mode === 'snapshot' ? (resolution) => setSnapshotBindings(bindSnapshotParts(shared.refs, resolution)) @@ -1142,7 +1315,7 @@ export const useSearchRun = () => { if (edit.axis === 'unit') { const unitEdit = sharedUnitEdit(parts, edit.request); - if (unitEdit) runDatasetEdit(unitEdit.override, unitEdit.touched); + if (unitEdit) runDatasetEdit(unitEdit.override, unitEdit.touched, 'shared'); return; } @@ -1173,6 +1346,7 @@ export const useSearchRun = () => { ), ), bound.map((part) => part.index), + 'shared', ); return; } @@ -1214,7 +1388,7 @@ export const useSearchRun = () => { ); }; - runDatasetEdit({ rewriteIdentity }, touched); + runDatasetEdit({ rewriteIdentity }, touched, 'shared'); }, [entityOptionsBySpec, facetOverride, parts, runDatasetEdit], ); @@ -1248,5 +1422,6 @@ export const useSearchRun = () => { editMeasure, editParsed, editShared, + searchIds, }; }; diff --git a/src/lib/chat/shareMode.ts b/src/lib/chat/shareMode.ts index bc2ebd6..5950285 100644 --- a/src/lib/chat/shareMode.ts +++ b/src/lib/chat/shareMode.ts @@ -79,12 +79,12 @@ export const readStudioShareRequest = ( return { kind: body.kind, mode: mode as ShareMode, saved, cardId: body.cardId }; }; -/** The props of the `share.create` event for a Studio share. */ +/** The props of the `share.create` event, for a Studio or a search share. */ export const shareCreateProps = (created: { id: string; - kind: StudioShareKind; + kind: ShareKind; mode: ShareMode; -}): { shareId: string; kind: StudioShareKind; mode: ShareMode } => ({ +}): { shareId: string; kind: ShareKind; mode: ShareMode } => ({ shareId: created.id, kind: created.kind, mode: created.mode, diff --git a/src/lib/chat/test/shareMode.test.ts b/src/lib/chat/test/shareMode.test.ts index 7eea098..dca6cd0 100644 --- a/src/lib/chat/test/shareMode.test.ts +++ b/src/lib/chat/test/shareMode.test.ts @@ -103,8 +103,13 @@ test('readStudioShareRequest: a request without a kind, a conversation, or a car }); test('shareCreateProps: the share.create event carries the kind and the mode within the props limit', () => { - const props = shareCreateProps({ id: 'abcd1234', kind: 'card', mode: 'live' }); + for (const [kind, mode] of [ + ['card', 'live'], + ['search', 'snapshot'], + ] as const) { + const props = shareCreateProps({ id: 'abcd1234', kind, mode }); - assert.deepEqual(props, { shareId: 'abcd1234', kind: 'card', mode: 'live' }); - assert.ok(fitsProps(props)); + assert.deepEqual(props, { shareId: 'abcd1234', kind, mode }); + assert.ok(fitsProps(props)); + } }); diff --git a/src/lib/events/track.ts b/src/lib/events/track.ts index db09f37..4e5df4d 100644 --- a/src/lib/events/track.ts +++ b/src/lib/events/track.ts @@ -17,19 +17,29 @@ import { posthogConfigured, sendEventToPosthog } from './posthog'; * - search.query an answered search (spec ids, measures and filters, places, * window, StatVar and place counts) * - search.empty a search the graph couldn't answer (the reasons) + * - search.edit an edit to a search answer, re-resolved (its outcome) + * - search.error a search post that failed (the error code) + * - search.abandon a search post left in flight (hidden, superseded or reset) + * - search.action a click on a search answer (pick, download, share, ...) + * - search.start_point the starting point shown, with no search call * - search.studio_handoff a Studio action on a search answer - * - studio.prompt a prompt sent in the Studio (turn, intent — not the text) + * - studio.prompt a prompt sent in the Studio (turn and intent, without the text) * - studio.card a card the harness put on the canvas (kind, source, years, unit) * - studio.session_end a Studio session left (turns, cards, pins) * - auth.signin a sign-in (provider) * - guest.wall_hit a guest ran out of free prompts * - feedback.submit a feedback sent (kind) * - embed.view an embed loaded in an iframe (share id, kind, host site) - * - share.create a Studio share link made (share id, kind, mode) + * - share.create a Studio or search share link made (share id, kind, mode) */ export const EVENT_NAMES = [ 'search.query', 'search.empty', + 'search.edit', + 'search.error', + 'search.abandon', + 'search.action', + 'search.start_point', 'search.studio_handoff', 'studio.prompt', 'studio.card', diff --git a/src/lib/search/searchClient.ts b/src/lib/search/searchClient.ts index 4051113..5a5f6d6 100644 --- a/src/lib/search/searchClient.ts +++ b/src/lib/search/searchClient.ts @@ -191,6 +191,10 @@ export type SearchEvent = * 2026-09-08). useSearchRun.ts's edit lane keys `editError` to * the entry index a `/input/entries/{i}/...` pointer names. */ pointer?: string | null; + /** The problem-details `status` and `trace_id`: an engine error + * frame carries both, and a non-2xx response forwards its own. */ + status?: number | null; + trace_id?: string | null; }; /** Mirrors SlotKey.as_ref(): the "axis:property_dcid" join key shared by @@ -1128,6 +1132,8 @@ export async function resolveStream( detail: detailText(body, res.status), code: body?.code ?? null, pointer: body?.pointer ?? null, + status: res.status, + trace_id: body?.trace_id ?? null, }); } return; diff --git a/src/lib/search/searchEvents.ts b/src/lib/search/searchEvents.ts index e958a19..9983068 100644 --- a/src/lib/search/searchEvents.ts +++ b/src/lib/search/searchEvents.ts @@ -1,23 +1,27 @@ /* eslint-disable @typescript-eslint/no-explicit-any -- reads the flat page Spec, typed shallowly like the rest of the engine-contract layer */ /** * The props of the search usage events (docs/analytics.md): `search.query`, - * `search.empty` and `search.studio_handoff` all describe one answer the - * same way. What the answer was (`kind`, and a declined answer's `reason`), - * the operations the question asked for, what resolved (each part's - * definition and counts) and what did not (each refused part's reason). + * `search.empty`, `search.edit` and `search.studio_handoff` all describe one + * answer the same way. They give what the answer was (`kind`, and a declined + * answer's `reason`), the operations the question asked for, what resolved + * (each part's definition and counts) and what did not (each refused part's + * reason). * * POST /api/events stores at most MAX_PROPS_BYTES of props, and one answer - * can serve hundreds of StatVars and places, so a part's props follow the - * question, never the data, and the free text is clipped. A role's places + * can serve hundreds of StatVars and places, so a part's props grow with the + * question alone and the free text is clipped. A role's places * are its named group, a sample of members and the count, and a slot's refs * are a sample and the count. The event then steps down a ladder until it - * fits: every source id, three source ids per part, a minimal form per part, - * and last a floor of fixed size, each marked `truncated` but the first two. - * The ladder runs on the complete props, `seeds` included, and every rung is - * measured as the route measures it (`fitsProps`). The API takes at most six + * fits. The rungs are every source id, three source ids per part, a minimal + * form per part, and last a floor of fixed size. The last two are marked + * `truncated`. + * The ladder runs on the complete props, `seeds` and a settled post's run + * fields included, and every rung is measured as the route measures it + * (`fitsProps`). The run fields stay on every rung, and the floor drops only + * their `byStep`. The API takes at most six * parts per question (`_PARSED_MAX_PARTS` in dfo-apis - * search/models/request.py); the floor holds only a fixed-length spec id and - * integer counts per part, so it needs no check. + * search/models/request.py), and the floor holds only a fixed-length spec id + * and integer counts per part, so it needs no check. */ import { fitsProps } from '../events/limits.ts'; @@ -26,6 +30,7 @@ import type { HydratedPart, IdentityBinding, OperationVerb, + Resolution, ResolveResponse, Spec, SpecIdentity, @@ -39,7 +44,51 @@ const MAX_SPEC_ID = 64; type Rung = 'full' | 'sampled' | 'minimal' | 'floor'; -export type SearchEventProps = { +/** Where a search's text came from: the prompt, an example sentence, the + * starting point's catalogue, a refine of the heading, a retry, a `?q=` + * permalink, a share link, or a dataset version link (`?spec=`). */ +export type SearchSource = + 'typed' | 'example' | 'catalogue' | 'refine' | 'retry' | 'permalink' | 'share' | 'version_link'; + +/** The useSearchRun callback that posted an edit. `replay` is a permalink's + * stored edits, posted once after its base run lands. */ +export type EditKind = + 'slot' | 'entity' | 'window' | 'facet' | 'pick' | 'measure' | 'parsed' | 'shared' | 'replay'; + +/** The engine's own verdict on an answer: a resolution's `status`, else its + * `kind`. dfo-apis logs the same value on its `resolve` line. */ +export type SearchOutcome = Resolution['status'] | 'catalogue' | 'declined'; + +export const searchOutcome = (response: ResolveResponse): SearchOutcome => + response.kind === 'resolution' ? response.status : response.kind; + +/** What every event of one post carries. `post` is 0 for the base run and + * counts up for each edit post of the same `searchId`. `hidden` marks a post + * whose page was hidden while it was in flight. */ +export type SearchRunProps = { + searchId: string; + post: number; + source: SearchSource; + edited: boolean; + editKind?: EditKind; + traceId: string | null; + /** Client time from the post's start to this event. */ + ms: number; + hidden?: true; +}; + +/** A settled post's own fields, kept on every rung of the fit. */ +export type SearchSettleProps = SearchRunProps & { + outcome: SearchOutcome; + /** The engine's `done.elapsed_ms`, trailing observation reads included. */ + engineMs: number; + /** The engine's `timing_ms.by_step`; dropped on the floor rung. */ + byStep?: Record; + /** The soft deadline cut the run short; its unfinished parts read as no_data. */ + timedOut: boolean; +}; + +export type SearchEventProps = Partial & { query: string; edited: boolean; kind: ResolveResponse['kind']; @@ -149,6 +198,7 @@ export function searchEventProps({ reason, parts, seeds, + run, }: { query: string; edited: boolean; @@ -157,6 +207,8 @@ export function searchEventProps({ parts: HydratedPart[]; /** A caller's own fields ride inside the fit, so none is added after it. */ seeds?: number; + /** A settled post's fields, or the ids a Studio hand-off carries. */ + run?: Partial; }): SearchEventProps { const operations = [ ...new Set(parts.flatMap((part) => (part.part?.operations ?? []).map((op) => op.verb))), @@ -213,7 +265,17 @@ export function searchEventProps({ ), })); + const runProps = (rung: Rung): Partial => { + if (!run) { + return {}; + } + + const { byStep, ...kept } = run; + + return rung === 'floor' || !byStep ? kept : { ...kept, byStep }; + }; const props = (rung: Rung): SearchEventProps => ({ + ...runProps(rung), query: rung === 'floor' ? '' : clip(query, MAX_QUERY), edited, kind, @@ -241,3 +303,156 @@ export function searchEventProps({ * `search.empty`, the data-gap signal. */ export const searchEventName = (props: SearchEventProps): 'search.query' | 'search.empty' => props.resolved.length > 0 || props.kind === 'catalogue' ? 'search.query' : 'search.empty'; + +/** The events one post can end on, and the abandon a hidden page sends. */ +export type SearchPostEventName = + 'search.query' | 'search.empty' | 'search.edit' | 'search.error' | 'search.abandon'; + +export type AbandonReason = 'hidden' | 'superseded' | 'reset'; + +/** The fields of the engine's `done` event a settle reads. */ +export type SearchDone = { trace_id: string; elapsed_ms: number; timed_out: boolean }; + +/** A failure as the stream reports it: a problem-details `code`, the HTTP + * `status`, and the `trace_id` when the engine sent one. */ +export type SearchFailure = { + code?: string | null; + status?: number | null; + traceId?: string | null; +}; + +export type SearchPostTracker = ReturnType; + +/** + * The usage events of one post: a base run (`post` 0) or one edit post. + * The post ends exactly once, on the first of `settle`, `fail` or `abandon`; + * every later call is a no-op, so a stream's trailing events, a late abort + * or a second hide cannot send a second terminal event. `hide` sends one + * `search.abandon` with reason `hidden` while the post is in flight and marks + * the post's later terminal event `hidden: true`. + */ +export function searchPostTracker( + post: { + searchId: string; + post: number; + source: SearchSource; + editKind: EditKind | null; + query: string; + }, + { + emit, + now, + }: { + emit: (name: SearchPostEventName, props: Record) => void; + now: () => number; + }, +) { + const startedAt = now(); + const edited = post.post > 0; + let ended = false; + let hidden = false; + let traceId: string | null = null; + let step: string | null = null; + + const runProps = (): SearchRunProps => ({ + searchId: post.searchId, + post: post.post, + source: post.source, + edited, + ...(post.editKind && { editKind: post.editKind }), + traceId, + ms: Math.round(now() - startedAt), + ...(hidden && { hidden: true as const }), + }); + const end = (): boolean => { + if (ended) { + return false; + } + + ended = true; + + return true; + }; + + const fail = ({ code, status, traceId: failedTrace }: SearchFailure) => { + if (!end()) { + return; + } + + traceId = failedTrace ?? traceId; + emit('search.error', { + ...runProps(), + query: clip(post.query, MAX_QUERY), + error: code ?? (status != null ? String(status) : 'stream'), + ...(status != null && { status }), + }); + }; + + return { + /** The last pipeline step the post reached, for an abandon. */ + step(name: string) { + step = name; + }, + /** The answer landed: its trace id joins any later error or abandon. */ + respond(response: ResolveResponse) { + traceId = response.diagnostics.trace_id; + }, + /** `response` and `parts` are the post's own answer with `done` + * applied. A post whose stream ended with no answer is an error. */ + settle({ + response, + parts, + done, + }: { + response: ResolveResponse | null; + parts: HydratedPart[]; + done: SearchDone; + }) { + if (!response) { + fail({ code: 'no_response', traceId: done.trace_id }); + + return; + } + + if (!end()) { + return; + } + + traceId = done.trace_id; + + const byStep = response.diagnostics.timing_ms?.by_step; + const props = searchEventProps({ + query: post.query, + edited, + kind: response.kind, + reason: response.kind === 'declined' ? response.reason : null, + parts, + run: { + ...runProps(), + outcome: searchOutcome(response), + engineMs: done.elapsed_ms, + ...(byStep && { byStep }), + timedOut: done.timed_out, + }, + }); + + emit(edited ? 'search.edit' : searchEventName(props), props); + }, + fail, + abandon(reason: Exclude) { + if (!end()) { + return; + } + + emit('search.abandon', { ...runProps(), reason, step }); + }, + hide() { + if (ended || hidden) { + return; + } + + emit('search.abandon', { ...runProps(), reason: 'hidden', step }); + hidden = true; + }, + }; +} diff --git a/src/lib/search/test/searchEvents.test.ts b/src/lib/search/test/searchEvents.test.ts index 6d6b9db..4f148a0 100644 --- a/src/lib/search/test/searchEvents.test.ts +++ b/src/lib/search/test/searchEvents.test.ts @@ -3,8 +3,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { fitsProps, MAX_PROPS_BYTES } from '../../events/limits.ts'; -import { searchEventProps } from '../searchEvents.ts'; -import type { HydratedPart } from '../types.ts'; +import { + searchEventProps, + searchOutcome, + searchPostTracker, + type SearchPostEventName, + type SearchSettleProps, +} from '../searchEvents.ts'; +import type { HydratedPart, ResolveResponse } from '../types.ts'; // "ODA from France to Kenya" against the dev API elects 308 CRS StatVars; // "to all recipients" resolves some 200 places. @@ -290,3 +296,235 @@ test('the floor holds a clipped spec id and integer counts per part, whatever th assert.equal(props.resolved[0].specId.length, 64); assert.ok(JSON.stringify(props).length < MAX_PROPS_BYTES / 4); }); + +const run: SearchSettleProps = { + searchId: 'search-1', + post: 0, + source: 'typed', + edited: false, + traceId: 'trace-1', + ms: 1200, + outcome: 'definite', + engineMs: 900, + byStep: { extract: 300, resolve: 500 }, + timedOut: false, +}; + +test('the run fields ride every rung, the floor without its step timings', () => { + const spec = aidPart.hydratedSpec as any; + const heavy = (index: number) => + ({ + ...aidPart, + index, + hydratedSpec: { + ...spec, + spec_id: 's'.repeat(1000), + identity: { ...spec.identity, shape: { kind: 'x'.repeat(3000) } }, + }, + }) as HydratedPart; + const { byStep, ...kept } = run; + const rungs = [ + [aidPart], + Array.from({ length: 6 }, (_, index) => ({ ...withSources(aidPart, 10, 140), index })), + Array.from({ length: 6 }, (_, index) => heavy(index)), + ] as HydratedPart[][]; + + for (const parts of rungs) { + const props = searchEventProps({ + query: 'q', + edited: false, + kind: 'resolution', + reason: null, + parts, + run, + }); + const floor = props.query === ''; + + assert.ok(fitsProps(props)); + assert.deepEqual( + { ...kept, ...(!floor && { byStep }) }, + Object.fromEntries( + Object.keys(run) + .filter((key) => key in props) + .map((key) => [key, props[key as keyof typeof props]]), + ), + ); + } +}); + +const response = (kind: string, extra: object = {}) => + ({ + kind, + diagnostics: { trace_id: 'trace-r', timing_ms: { total: 800, by_step: { extract: 100 } } }, + parts: [], + scope: { place: null }, + ...extra, + }) as unknown as ResolveResponse; + +test('the outcome is a resolution status, else the answer kind', () => { + for (const status of ['definite', 'candidates', 'no_data', 'unsupported', 'mixed']) { + assert.equal(searchOutcome(response('resolution', { status })), status); + } + assert.equal(searchOutcome(response('catalogue')), 'catalogue'); + assert.equal(searchOutcome(response('declined', { reason: 'out_of_scope' })), 'declined'); +}); + +/** A tracker over a fake clock, and the events it sent. */ +const tracked = (post = 0) => { + const sent: [SearchPostEventName, Record][] = []; + let clock = 1000; + const tracker = searchPostTracker( + { + searchId: 'search-1', + post, + source: 'example', + editKind: post > 0 ? 'slot' : null, + query: 'ODA to Kenya', + }, + { emit: (name, props) => sent.push([name, props]), now: () => clock }, + ); + + return { tracker, sent, advance: (ms: number) => (clock += ms) }; +}; + +const done = { trace_id: 'trace-d', elapsed_ms: 2500, timed_out: true }; + +test('a post sends one terminal event: the first of settle, fail and abandon', () => { + const { tracker, sent, advance } = tracked(); + + tracker.respond(response('resolution', { status: 'definite' })); + advance(3000); + tracker.settle({ + response: response('resolution', { status: 'definite' }), + parts: [aidPart], + done, + }); + tracker.settle({ response: response('catalogue'), parts: [], done }); + tracker.fail({ code: 'STALL_TIMEOUT' }); + tracker.abandon('superseded'); + tracker.hide(); + + assert.equal(sent.length, 1); + + const [[name, props]] = sent; + + assert.equal(name, 'search.query'); + assert.deepEqual( + { + searchId: props.searchId, + post: props.post, + source: props.source, + edited: props.edited, + traceId: props.traceId, + ms: props.ms, + outcome: props.outcome, + engineMs: props.engineMs, + byStep: props.byStep, + timedOut: props.timedOut, + hidden: props.hidden, + }, + { + searchId: 'search-1', + post: 0, + source: 'example', + edited: false, + traceId: 'trace-d', + ms: 3000, + outcome: 'definite', + engineMs: 2500, + byStep: { extract: 100 }, + timedOut: true, + hidden: undefined, + }, + ); +}); + +test('a base run that answered nothing is search.empty, an edit post is search.edit', () => { + const base = tracked(); + const edit = tracked(2); + + base.tracker.settle({ + response: response('resolution', { status: 'no_data' }), + parts: [refusedPart], + done, + }); + edit.tracker.settle({ + response: response('resolution', { status: 'no_data' }), + parts: [refusedPart], + done, + }); + + assert.equal(base.sent[0][0], 'search.empty'); + assert.equal(edit.sent[0][0], 'search.edit'); + assert.equal(edit.sent[0][1].post, 2); + assert.equal(edit.sent[0][1].edited, true); + assert.equal(edit.sent[0][1].editKind, 'slot'); + assert.equal(edit.sent[0][1].outcome, 'no_data'); +}); + +test('a hidden post sends one hidden abandon and marks its later settle', () => { + const { tracker, sent, advance } = tracked(); + + tracker.step('extract'); + advance(400); + tracker.hide(); + tracker.hide(); + advance(600); + tracker.settle({ response: response('catalogue'), parts: [], done }); + + assert.deepEqual( + sent.map(([name, props]) => [name, props.reason, props.step, props.ms, props.hidden]), + [ + ['search.abandon', 'hidden', 'extract', 400, undefined], + ['search.query', null, undefined, 1000, true], + ], + ); +}); + +test('a settled post ignores a later hide, and a superseded one abandons once', () => { + const settled = tracked(); + const superseded = tracked(1); + + settled.tracker.settle({ response: response('catalogue'), parts: [], done }); + settled.tracker.hide(); + superseded.tracker.abandon('superseded'); + superseded.tracker.abandon('reset'); + + assert.deepEqual( + settled.sent.map(([name]) => name), + ['search.query'], + ); + assert.deepEqual( + superseded.sent.map(([name, props]) => [name, props.reason, props.traceId]), + [['search.abandon', 'superseded', null]], + ); +}); + +test('a failure carries its code, else its HTTP status, and the trace it names', () => { + const coded = tracked(); + const status = tracked(); + const silent = tracked(); + const noAnswer = tracked(); + + coded.tracker.fail({ code: 'over_ceiling', status: 422, traceId: 'trace-e' }); + status.tracker.respond(response('catalogue')); + status.tracker.fail({ status: 503 }); + silent.tracker.fail({}); + noAnswer.tracker.settle({ response: null, parts: [], done }); + + assert.deepEqual( + [coded, status, silent, noAnswer].map(({ sent: [[name, props]] }) => [ + name, + props.error, + props.status, + props.traceId, + props.query, + ]), + [ + ['search.error', 'over_ceiling', 422, 'trace-e', 'ODA to Kenya'], + ['search.error', '503', 503, 'trace-r', 'ODA to Kenya'], + ['search.error', 'stream', undefined, null, 'ODA to Kenya'], + ['search.error', 'no_response', undefined, 'trace-d', 'ODA to Kenya'], + ], + ); +}); From 4e3b06ef83f1d838e72652970154e8e23bf9d9ba Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 15 Oct 2026 11:17:27 +0200 Subject: [PATCH 4/7] feat(share): record share.open when a share link is opened Nothing counted a share link being opened: embeds had embed.view, but the /s/ 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. --- docs/analytics.md | 5 +++-- src/app/embed/[id]/page.tsx | 7 +------ src/app/s/[id]/page.tsx | 28 ++++++++++++++++++++++++++++ src/lib/events/referer.ts | 7 +++++++ src/lib/events/track.ts | 2 ++ 5 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 src/lib/events/referer.ts diff --git a/docs/analytics.md b/docs/analytics.md index c82c7c7..447d494 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -26,6 +26,7 @@ The browser stores `visitor_id` only after the visitor accepts "DFO usage statis | `feedback.submit` | a feedback submission | `kind` (`chat` / `widget`) | | `share.create` | a share link stored | `shareId`, `kind` (`conversation` / `canvas` / `card`, or `search` for a search result), `mode` (`live` / `snapshot`). A search share joins its `search.action` through `shareId` | | `embed.view` | an `/embed/` page rendered | `shareId`, `kind` (`card` / `canvas` / `conversation`), `host` (the hostname of the page the iframe sits on, else null) | +| `share.open` | an `/s/` page loaded, for a Studio share and for a search share (which then redirects to `/data?share=`) | `shareId`, `kind` (`conversation` / `canvas` / `card` / `search`), `mode` (`live` / `snapshot`), `host` (the hostname of the page the link was followed from, else null). Carries no visitor id and no user id; a search share's run on `/data` reports `source: 'share'` separately | Every search post, the base run and each edit after it, ends on one terminal event: `search.query`, `search.empty`, `search.edit`, `search.error`, or a `superseded` or `reset` abandon. A page closed mid-post leaves only its `hidden` abandon. `useSearchRun` gives each post a tracker from `searchPostTracker()` in `src/lib/search/searchEvents.ts`, which sends only the first terminal event it is asked for. These run fields are on every one of them: @@ -52,7 +53,7 @@ where a.name = 'search.abandon' and a.props->>'reason' = 'hidden' Browser code calls `track()` from `src/lib/events/client.ts`, which posts to `POST /api/events` (allow-listed names, props capped at 8 KB, always 204); server code calls `track()` from `src/lib/events/track.ts` directly. The list of names lives in `track.ts` — add there first. -When `POSTHOG_KEY` and `POSTHOG_HOST` are set, `track()` also forwards each event server-side to PostHog (EU cloud) through `sendEventToPosthog()` in `src/lib/events/posthog.ts`. The event keeps its props, minus any key starting with `$`, and adds `environment`, `page`, `signed_in` and `country`. Its `distinct_id` is the visitor id for a consented visitor, else the account id for a signed-in one. An event with neither gets a fresh random UUID and `$process_person_profile: false`, so PostHog creates no person for it. Each event carries the browser request's `User-Agent` as `$raw_user_agent`, which PostHog's bot filter reads. `country` is the two-letter code Cloudflare puts in the `CF-IPCountry` header, or null when Cloudflare has no location (`XX`) or the visitor uses Tor (`T1`). Every event sets `$geoip_disable: true`, because PostHog would otherwise place it at the Cloud Run server, and no event carries the visitor's IP. All five server callers pass the browser request's headers: `POST /api/events`, feedback, shares (Studio and search), embed renders and the sign-in callback. An event with no request behind it passes `headers: null` and carries no user agent and a null `country`. +When `POSTHOG_KEY` and `POSTHOG_HOST` are set, `track()` also forwards each event server-side to PostHog (EU cloud) through `sendEventToPosthog()` in `src/lib/events/posthog.ts`. The event keeps its props, minus any key starting with `$`, and adds `environment`, `page`, `signed_in` and `country`. Its `distinct_id` is the visitor id for a consented visitor, else the account id for a signed-in one. An event with neither gets a fresh random UUID and `$process_person_profile: false`, so PostHog creates no person for it. Each event carries the browser request's `User-Agent` as `$raw_user_agent`, which PostHog's bot filter reads. `country` is the two-letter code Cloudflare puts in the `CF-IPCountry` header, or null when Cloudflare has no location (`XX`) or the visitor uses Tor (`T1`). Every event sets `$geoip_disable: true`, because PostHog would otherwise place it at the Cloud Run server, and no event carries the visitor's IP. All six server callers pass the browser request's headers: `POST /api/events`, feedback, shares (Studio and search), share page loads, embed renders and the sign-in callback. An event with no request behind it passes `headers: null` and carries no user agent and a null `country`. A resolved part records what the question asked for, and counts the StatVars and places it expanded to: an aid query expands to hundreds of each, which would pass the 8 KB cap. A role asked for as a named group, such as the European Union, records the group, three of its members and the member count. A set-valued filter with more than three refs records three and the count. `sources` lists every source id while the whole event fits the cap, and the first three per part when it does not; `sourceCount` is always the full number, so a list shorter than the count is a sample. The run and settle fields stay on every form, and the floor drops only `byStep`. If the event still exceeds the cap, each resolved part keeps only `specId`, `measure`, `statVarCount`, `entityCount`, `sourceCount` and the per-role place `count`, and the event carries `truncated: true`. If that form is still too large, the event ends on a fixed-size floor: an empty `query`, no refused parts, and per resolved part only a clipped `specId` and the integer counts. The ladder runs on the complete props, `seeds` included, and measures the JSON length as the route does; the API's limit of six parts bounds the floor. @@ -77,7 +78,7 @@ Source maps upload during `next build` when `SENTRY_ORG`, `SENTRY_PROJECT` and ` Consent Mode v2 runs in advanced mode. An inline script in `src/app/layout.tsx` sets every consent type to `denied` on every host, ahead of the Usercentrics loader, and GA sends cookieless pings until the visitor chooses. Usercentrics has Google Consent Mode on and a "Google Analytics 4" service, and sends the `consent update` when the visitor chooses. -Embeds load neither Usercentrics nor GA. On an `/embed/` path (`isEmbedPath()` in `src/lib/embed.ts`) the root layout's inline loader skips injecting the Usercentrics script and `GoogleAnalytics` renders nothing, because a banner would cover the card and iframe storage is partitioned per host site, so a reader would be asked again on every partner site. The server counts each embed render as `embed.view` instead, with no visitor id, no user id and nothing stored on the device. The count is iframe loads, including crawlers that render a host page and direct opens of the embed URL. `host` keeps only the hostname of the `Referer` and is null when the host page's referrer policy strips it. +Embeds load neither Usercentrics nor GA. On an `/embed/` path (`isEmbedPath()` in `src/lib/embed.ts`) the root layout's inline loader skips injecting the Usercentrics script and `GoogleAnalytics` renders nothing, because a banner would cover the card and iframe storage is partitioned per host site, so a reader would be asked again on every partner site. The server counts each embed render as `embed.view` instead, and each share page load as `share.open`, both with no visitor id, no user id and nothing stored on the device. The count is iframe loads, including crawlers that render a host page and direct opens of the embed URL. `host` keeps only the hostname of the `Referer` and is null when the host page's referrer policy strips it. The "Privacy settings" button in the footer (`src/components/organisms/Footer/Footer.tsx`) calls `showPrivacySettings()` from `src/lib/consent.ts`, which opens the Usercentrics settings layer so a visitor can change either choice after the first banner. diff --git a/src/app/embed/[id]/page.tsx b/src/app/embed/[id]/page.tsx index 85bc0e9..9ba81ca 100644 --- a/src/app/embed/[id]/page.tsx +++ b/src/app/embed/[id]/page.tsx @@ -5,6 +5,7 @@ import { after } from 'next/server'; import EmbedView from '@/components/organisms/EmbedView/EmbedView'; import { getShare } from '@/lib/chat/shareStore'; +import { refererHost } from '@/lib/events/referer'; import { track } from '@/lib/events/track'; import { sharedViewOf } from '@/lib/studio/liveCards'; @@ -21,12 +22,6 @@ export const generateMetadata = async ({ params }: EmbedPageProps): Promise - URL.parse(referer ?? '')?.hostname || null; - // The iframe view of a share: just the read-only sheet, or the one card, // edge to edge. A search share has no sheet to embed. A live share's data // cards leave the server held and are filled in the browser (sharedViewOf). diff --git a/src/app/s/[id]/page.tsx b/src/app/s/[id]/page.tsx index 2db5e1e..9ef5e96 100644 --- a/src/app/s/[id]/page.tsx +++ b/src/app/s/[id]/page.tsx @@ -1,8 +1,13 @@ import type { Metadata } from 'next'; +import { headers } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; +import { after } from 'next/server'; import Studio from '@/components/organisms/Studio/Studio'; +import { isLiveStudioShare, searchShareMode, type ShareMode } from '@/lib/chat/shareMode'; import { getShare } from '@/lib/chat/shareStore'; +import { refererHost } from '@/lib/events/referer'; +import { track } from '@/lib/events/track'; import { sharedViewOf } from '@/lib/studio/liveCards'; type SharePageProps = { params: Promise<{ id: string }> }; @@ -20,6 +25,16 @@ export const generateMetadata = async ({ params }: SharePageProps): Promise { + if (row.kind === 'search') { + return searchShareMode(row.mode); + } + + return isLiveStudioShare(row) ? 'live' : 'snapshot'; +}; + // A public share: the Studio, read-only, on the stored copy. No account // needed to look. A live share's data cards leave the server held and are // filled in the browser on current data (sharedViewOf). @@ -31,6 +46,19 @@ const SharePage = async ({ params }: SharePageProps) => { notFound(); } + // Counted here, server-side, with no identifier and nothing stored on the + // device, so it needs no consent. Headers are read during render: inside + // after() they throw. Scheduled before the redirect below, which throws. + const incoming = await headers(); + const host = refererHost(incoming.get('referer')); + after(() => + track( + 'share.open', + { shareId: row.id, kind: row.kind, mode: modeOf(row), host }, + { headers: incoming }, + ), + ); + // A search result opens on the search page itself, which loads the // share's references and replays (or reads back) the data. if (row.kind === 'search') { diff --git a/src/lib/events/referer.ts b/src/lib/events/referer.ts new file mode 100644 index 0000000..ea66834 --- /dev/null +++ b/src/lib/events/referer.ts @@ -0,0 +1,7 @@ +/** + * The host site of the page a request came from, as a bare hostname: the + * Referer's path and query can carry personal data, so they never leave + * here. Null when the referrer policy strips it. + */ +export const refererHost = (referer: string | null): string | null => + URL.parse(referer ?? '')?.hostname || null; diff --git a/src/lib/events/track.ts b/src/lib/events/track.ts index 4e5df4d..91671f5 100644 --- a/src/lib/events/track.ts +++ b/src/lib/events/track.ts @@ -31,6 +31,7 @@ import { posthogConfigured, sendEventToPosthog } from './posthog'; * - feedback.submit a feedback sent (kind) * - embed.view an embed loaded in an iframe (share id, kind, host site) * - share.create a Studio or search share link made (share id, kind, mode) + * - share.open a share link opened at /s/ (share id, kind, mode, host site) */ export const EVENT_NAMES = [ 'search.query', @@ -49,6 +50,7 @@ export const EVENT_NAMES = [ 'feedback.submit', 'embed.view', 'share.create', + 'share.open', ] as const; export type EventName = (typeof EVENT_NAMES)[number]; From 59e2c93808fbbe2ba1d83ad173c85796dc7c4776 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 15 Oct 2026 11:41:24 +0200 Subject: [PATCH 5/7] feat(events): log usage events to Cloud Logging; fix abandon and action 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. --- docs/analytics.md | 17 +++-- .../organisms/DataSearch/DataSearch.tsx | 38 ++++++---- .../organisms/DataSearch/SearchPanel.tsx | 8 +- .../DataSearch/useDatasetDownload.ts | 11 ++- .../organisms/DataSearch/useSearchRun.ts | 28 ++++++- src/lib/events/eventLog.ts | 50 +++++++++++++ src/lib/events/track.ts | 20 +++-- src/lib/search/searchEvents.ts | 24 +++++- src/lib/search/test/searchEvents.test.ts | 31 +++++++- src/lib/search/test/unmountTeardown.test.ts | 47 ++++++++++++ src/lib/search/unmountTeardown.ts | 40 ++++++++++ src/lib/test/events-log.test.ts | 74 +++++++++++++++++++ 12 files changed, 345 insertions(+), 43 deletions(-) create mode 100644 src/lib/events/eventLog.ts create mode 100644 src/lib/search/test/unmountTeardown.test.ts create mode 100644 src/lib/search/unmountTeardown.ts create mode 100644 src/lib/test/events-log.test.ts diff --git a/docs/analytics.md b/docs/analytics.md index 447d494..f5fa60b 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -1,6 +1,6 @@ # Analytics and error monitoring -Usage events go to our own database, errors to Sentry, and page views to Google Analytics on production, behind the cookie banner. +Usage events go to our own database, to Cloud Logging and to PostHog, errors to Sentry, and page views to Google Analytics on production, behind the cookie banner. ## Usage events @@ -14,10 +14,10 @@ The browser stores `visitor_id` only after the visitor accepts "DFO usage statis | `search.empty` | a base run that answered nothing: no part resolved, or the question was declined | same shape, `resolved` empty; the data-gap signal, though a run with `timedOut` true reads as empty for parts the deadline cut | | `search.edit` | an edit post settled: a change to an answer, re-resolved | same shape as `search.query`, with `edited` true and `editKind` | | `search.error` | a post failed | the run fields, `query`, `error` (the problem-details `code`, else the HTTP status, else `stream` for a stream that broke or closed without an answer), `status` (the HTTP status, when there was one) | -| `search.abandon` | a post left in flight: the page was hidden, a newer search or edit replaced it, the reader opened the starting point, or the reader reset the page | the run fields, `reason` (`hidden` / `superseded` / `reset`), `step` (the last pipeline step reached, null on an edit post) | +| `search.abandon` | a post left in flight: the page was hidden, a newer search or edit replaced it, the reader opened the starting point, the reader reset the page, or the reader went to another page of the site | the run fields, `reason` (`hidden` / `superseded` / `reset` / `left`), `step` (the last pipeline step reached, null on an edit post) | | `search.start_point` | the starting point shown from the page's own catalogue, with no search call | `source` | | `search.studio_handoff` | a click on a Studio action on a search answer | same shape, plus `seeds` (the seeds handed to Studio, 0 when none could be stored), `searchId` (absent on the starting point) and `traceId` | -| `search.action` | a visitor acted on the answer on screen | `searchId` and `traceId` (the search and the post whose answer is on screen, null on the starting point), `action`, and per action: `pick_reading` (a reading card's "Use this reading") with `partIndex` and `specId`; `download` (the side panel's download reached its file) with `partIndex`, `specId`, `format` and `bundle`; `share` (a share link minted, the first time the Share box shows a tab) with `mode` (`live` / `snapshot`) and `shareId` | +| `search.action` | a visitor acted on the answer on screen | `searchId` and `traceId` (the search and the post whose answer was on screen when the visitor acted, read before a share or download request starts; null on the starting point), `action`, and per action: `pick_reading` (a reading card's "Use this reading") with `partIndex` and `specId`; `download` (the side panel's download reached its file) with `partIndex`, `specId`, `format` and `bundle`; `share` (a share link minted, the first time the Share box shows a tab) with `mode` (`live` / `snapshot`) and `shareId` | | `studio.prompt` | a prompt sent in the Studio | `turn`, `intent` (`new` / `follow_up` / `edit`), `length`, `guest` | | `studio.card` | a card the harness put on the canvas | `kind`, `turn`, `source`, `indicators` (DCIDs), `years`, `unit` (in words with its magnitude and price basis, `US Dollar, millions, constant 2023 prices`; a composed unit as its numerator and denominator either side of "per", each part's magnitude and price basis in brackets beside it, `US Dollar per Total population` or `Euro (constant 2020 prices) per Total population`; a percentage as `Percent`; null when the card states none), `guest` | | `studio.session_end` | New Chat, or the page is left | `turns`, `cards`, `pins`, `guest` | @@ -28,7 +28,7 @@ The browser stores `visitor_id` only after the visitor accepts "DFO usage statis | `embed.view` | an `/embed/` page rendered | `shareId`, `kind` (`card` / `canvas` / `conversation`), `host` (the hostname of the page the iframe sits on, else null) | | `share.open` | an `/s/` page loaded, for a Studio share and for a search share (which then redirects to `/data?share=`) | `shareId`, `kind` (`conversation` / `canvas` / `card` / `search`), `mode` (`live` / `snapshot`), `host` (the hostname of the page the link was followed from, else null). Carries no visitor id and no user id; a search share's run on `/data` reports `source: 'share'` separately | -Every search post, the base run and each edit after it, ends on one terminal event: `search.query`, `search.empty`, `search.edit`, `search.error`, or a `superseded` or `reset` abandon. A page closed mid-post leaves only its `hidden` abandon. `useSearchRun` gives each post a tracker from `searchPostTracker()` in `src/lib/search/searchEvents.ts`, which sends only the first terminal event it is asked for. These run fields are on every one of them: +Every search post, the base run and each edit after it, ends on one terminal event: `search.query`, `search.empty`, `search.edit`, `search.error`, or a `superseded`, `reset` or `left` abandon. A page closed mid-post leaves only its `hidden` abandon. `useSearchRun` gives each post a tracker from `searchPostTracker()` in `src/lib/search/searchEvents.ts`, which sends only the first terminal event it is asked for. These run fields are on every one of them: - `searchId`: a UUID minted when a search starts (a query, a permalink, a share or version link), kept by its edit posts. - `post`: 0 for the base run, then 1, 2, … for each edit post of the same search. @@ -40,14 +40,15 @@ Every search post, the base run and each edit after it, ends on one terminal eve A settled post adds `outcome` (a resolution's `status`, `definite` / `candidates` / `no_data` / `unsupported` / `mixed`, else `catalogue` or `declined`), `engineMs` (the engine's `done.elapsed_ms`), `byStep` (the engine's per-step timings) and `timedOut` (the engine's soft deadline cut the run short). -A post sends `search.abandon` with reason `hidden` the first time the page is hidden while it is in flight. Background tabs keep streaming, so the post usually settles later, with `hidden: true`. A true abandon is a `hidden` abandon with no later terminal event for the same `(searchId, post)`: +A post sends `search.abandon` with reason `hidden` the first time the page is hidden while it is in flight. Background tabs keep streaming, so the post usually settles later, with `hidden: true`. A post still in flight when the reader goes to another page of the site ends on a `left` abandon, and its stream is aborted. A true abandon, a page closed with the post in flight, is a `hidden` abandon with no terminal event for the same `(searchId, post)`: no settle, no error, and no `superseded`, `reset` or `left` abandon. The tracker sends `hidden` only before the post ends, so any such event came after it: ```sql select a.props->>'searchId', a.props->>'post' from event a where a.name = 'search.abandon' and a.props->>'reason' = 'hidden' and not exists ( select 1 from event t - where t.name in ('search.query', 'search.empty', 'search.edit', 'search.error') + where (t.name in ('search.query', 'search.empty', 'search.edit', 'search.error') + or (t.name = 'search.abandon' and t.props->>'reason' <> 'hidden')) and t.props->>'searchId' = a.props->>'searchId' and t.props->>'post' = a.props->>'post'); ``` @@ -55,6 +56,8 @@ Browser code calls `track()` from `src/lib/events/client.ts`, which posts to `PO When `POSTHOG_KEY` and `POSTHOG_HOST` are set, `track()` also forwards each event server-side to PostHog (EU cloud) through `sendEventToPosthog()` in `src/lib/events/posthog.ts`. The event keeps its props, minus any key starting with `$`, and adds `environment`, `page`, `signed_in` and `country`. Its `distinct_id` is the visitor id for a consented visitor, else the account id for a signed-in one. An event with neither gets a fresh random UUID and `$process_person_profile: false`, so PostHog creates no person for it. Each event carries the browser request's `User-Agent` as `$raw_user_agent`, which PostHog's bot filter reads. `country` is the two-letter code Cloudflare puts in the `CF-IPCountry` header, or null when Cloudflare has no location (`XX`) or the visitor uses Tor (`T1`). Every event sets `$geoip_disable: true`, because PostHog would otherwise place it at the Cloud Run server, and no event carries the visitor's IP. All six server callers pass the browser request's headers: `POST /api/events`, feedback, shares (Studio and search), share page loads, embed renders and the sign-in callback. An event with no request behind it passes `headers: null` and carries no user agent and a null `country`. +`track()` also writes each event as one JSON line on stdout, through `logEvent()` in `src/lib/events/eventLog.ts`, before the database insert and the PostHog forward, so the line exists whether either of them fails. Cloud Logging stores it as a structured entry with `jsonPayload.message = "event"`: `{severity: "INFO", message: "event", name, props, page, signedIn}`. Its `page` is the path alone, with any query string or fragment dropped, as on the request log line; the table and PostHog keep `page` as sent. The line holds the event's full props, including the clipped query text of the search events, and keeps it as long as the log bucket's retention. It holds no `userId` and no `visitorId`: `signedIn` says only whether a user was signed in. A search event's `traceId` prop is the `trace_id` on the dfo-apis `resolve` log line, so the two join on it in SQL once the log bucket is linked to BigQuery. + A resolved part records what the question asked for, and counts the StatVars and places it expanded to: an aid query expands to hundreds of each, which would pass the 8 KB cap. A role asked for as a named group, such as the European Union, records the group, three of its members and the member count. A set-valued filter with more than three refs records three and the count. `sources` lists every source id while the whole event fits the cap, and the first three per part when it does not; `sourceCount` is always the full number, so a list shorter than the count is a sample. The run and settle fields stay on every form, and the floor drops only `byStep`. If the event still exceeds the cap, each resolved part keeps only `specId`, `measure`, `statVarCount`, `entityCount`, `sourceCount` and the per-role place `count`, and the event carries `truncated: true`. If that form is still too large, the event ends on a fixed-size floor: an empty `query`, no refused parts, and per resolved part only a clipped `specId` and the integer counts. The ladder runs on the complete props, `seeds` included, and measures the JSON length as the route does; the API's limit of six parts bounds the floor. The table is meant to be exported to BigQuery alongside the API usage log lines; until then it is plain SQL, e.g. the most-requested measures: @@ -82,7 +85,7 @@ Embeds load neither Usercentrics nor GA. On an `/embed/` path (`isEmbedPath()` i The "Privacy settings" button in the footer (`src/components/organisms/Footer/Footer.tsx`) calls `showPrivacySettings()` from `src/lib/consent.ts`, which opens the Usercentrics settings layer so a visitor can change either choice after the first banner. -Enhanced measurement records page views on App Router navigations (history changes), outbound clicks, scrolls, site search (`?q=`) and file downloads, so the site sends GA only its config call. The `track()` events above go to our own database and PostHog; GA receives none of them. +Enhanced measurement records page views on App Router navigations (history changes), outbound clicks, scrolls, site search (`?q=`) and file downloads, so the site sends GA only its config call. The `track()` events above go to our own database, Cloud Logging and PostHog; GA receives none of them. To check it on production, open the network panel and filter on `google-analytics.com/g/collect`. Before consent the requests carry `gcs=G100`, and after accepting in the banner they carry `gcs=G111`. The visit then shows in GA under Reports → Realtime. Localhost and dev make no `gtag/js` request. diff --git a/src/components/organisms/DataSearch/DataSearch.tsx b/src/components/organisms/DataSearch/DataSearch.tsx index 36c027c..0764a6d 100644 --- a/src/components/organisms/DataSearch/DataSearch.tsx +++ b/src/components/organisms/DataSearch/DataSearch.tsx @@ -56,7 +56,7 @@ import { snapshotLiveNote, studioHandoffParts, } from '@/lib/search/resultNotes'; -import { searchEventProps, type SearchSource } from '@/lib/search/searchEvents'; +import { searchEventProps, startSearchAction, type SearchSource } from '@/lib/search/searchEvents'; import { earlySentenceTokens, tokenPart } from '@/lib/search/sentencePlan'; import { handoffEdited, @@ -317,9 +317,10 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { searchIds, ]); - // A visitor's action on the answer on screen (docs/analytics.md). - const trackAction = useCallback( - (action: SearchAction) => track('search.action', { ...searchIds(), ...action }), + // A visitor's action on the answer on screen (docs/analytics.md): the + // ids are read as the visitor acts, and the returned sender records it. + const startAction = useCallback( + () => startSearchAction(searchIds, track), [searchIds], ); @@ -754,8 +755,10 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { specId: part.hydratedSpec!.spec_id, label: part.variableText ?? part.hydratedSpec?.shape?.label ?? '—', })); - const createShare: CreateSearchShare = (mode) => - createSearchShare({ + const createShare: CreateSearchShare = (mode) => { + const sendAction = startAction(); + + return createSearchShare({ mode, query, parts: sharedParts.map(searchSharePart), @@ -764,10 +767,11 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { return result; } - trackAction({ action: 'share', mode, shareId: result.id }); + sendAction({ action: 'share', mode, shareId: result.id }); return { ...result, datasets: sharedDatasets }; }); + }; // A part whose question asked for more than search does carries an // operation_not_applied departure, whose action hands it to Studio; // without one, the side panel offers Studio itself, so a page shows one @@ -1087,13 +1091,17 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { note={downloadNote(definiteParts[0].part?.warnings)} createShare={createShare} onStudio={studioDeparture ? undefined : openStudio} - onDownload={(downloaded) => - trackAction({ - action: 'download', - partIndex: definiteParts[0].index, - ...downloaded, - }) - } + onDownloadStart={() => { + const sendAction = startAction(); + const partIndex = definiteParts[0].index; + + return (downloaded) => + sendAction({ + action: 'download', + partIndex, + ...downloaded, + }); + }} /> )} @@ -1121,7 +1129,7 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { part={part.part} prefix={parts.length > 1 ? part.variableText : null} onPick={(spec) => { - trackAction({ + startAction()({ action: 'pick_reading', partIndex: part.index, specId: spec.spec_id, diff --git a/src/components/organisms/DataSearch/SearchPanel.tsx b/src/components/organisms/DataSearch/SearchPanel.tsx index 271f96f..7ca5533 100644 --- a/src/components/organisms/DataSearch/SearchPanel.tsx +++ b/src/components/organisms/DataSearch/SearchPanel.tsx @@ -22,7 +22,7 @@ const SearchPanel = ({ note, createShare, onStudio, - onDownload, + onDownloadStart, }: { spec: any; // Says a download may differ from the preview (resultNotes downloadNote). @@ -33,8 +33,8 @@ const SearchPanel = ({ // hidden without it, as it is while an operation_not_applied departure // offers Studio. onStudio?: () => void; - // Records a download that reached its file. - onDownload?: (downloaded: DatasetDownloaded) => void; + // Called as a download starts; records it once it reaches its file. + onDownloadStart?: () => (downloaded: DatasetDownloaded) => void; }) => { const [detailsOpen, setDetailsOpen] = useState(false); // The share box pops like the other menus here: 'closed' keeps it mounted @@ -74,7 +74,7 @@ const SearchPanel = ({ specId: spec?.spec_id, definition: spec?.definition, entities: spec?.entities, - onDownload, + onDownloadStart, }); const rowCount = downloadRowCount(spec); diff --git a/src/components/organisms/DataSearch/useDatasetDownload.ts b/src/components/organisms/DataSearch/useDatasetDownload.ts index 2c395c1..ba117c9 100644 --- a/src/components/organisms/DataSearch/useDatasetDownload.ts +++ b/src/components/organisms/DataSearch/useDatasetDownload.ts @@ -98,8 +98,10 @@ export type DatasetDownloadTarget = { // The answer's bound entities, whose type heads the observationAbout // group of extra columns ("Country"). entities?: { role?: string; entity_type?: string | null }[] | null; - // Called once a download's file opens, with what was rendered. - onDownload?: (downloaded: DatasetDownloaded) => void; + // Called as a download starts. The function it returns is called once + // the file opens, with what was rendered, so a caller can bind the + // record to what was on screen when the visitor clicked. + onDownloadStart?: () => (downloaded: DatasetDownloaded) => void; }; /** A download that reached its file: the spec and the choices it used. */ @@ -115,7 +117,7 @@ export const useDatasetDownload = ({ specId, definition, entities, - onDownload, + onDownloadStart, }: DatasetDownloadTarget) => { const choices = useSyncExternalStore(subscribeChoices, readChoices, serverChoices); const [optionsOpen, setOptionsOpen] = useState(false); @@ -237,6 +239,7 @@ export const useDatasetDownload = ({ } const controller = new AbortController(); + const downloaded = onDownloadStart?.(); downloadAbortRef.current = controller; setDownloading(true); @@ -318,7 +321,7 @@ export const useDatasetDownload = ({ } window.location.assign(artifactHref(artifact.url)); - onDownload?.({ specId, format, bundle }); + downloaded?.({ specId, format, bundle }); } catch (caught) { if (controller.signal.aborted) { return; diff --git a/src/components/organisms/DataSearch/useSearchRun.ts b/src/components/organisms/DataSearch/useSearchRun.ts index aabe335..47d110a 100644 --- a/src/components/organisms/DataSearch/useSearchRun.ts +++ b/src/components/organisms/DataSearch/useSearchRun.ts @@ -131,8 +131,10 @@ * an `error` event or a thrown error fails, and a stream that closes with * neither fails as `stream`. A post aborted by a newer one or by * `showStartingPoint` abandons as `superseded` at the abort site, and one - * aborted by `reset` abandons as `reset`. The tracker sends only the first of - * these, so each post sends one terminal event. A page hidden mid-post sends a `hidden` + * aborted by `reset` abandons as `reset`. A client-side navigation that + * unmounts the search abandons each post in flight as `left` and aborts its + * stream. The tracker sends only the first of these, so each post sends one + * terminal event. A page hidden mid-post sends a `hidden` * abandon from the visibility listener below. `searchIds()` reads the * current search's id and the trace of the answer on screen, for the events * a visitor's click on that answer sends. @@ -204,10 +206,12 @@ import type { import { searchPostTracker, type EditKind, + type SearchIds, type SearchPostTracker, type SearchSource, } from '@/lib/search/searchEvents'; import { sharedUnitEdit } from '@/lib/search/unitPicker'; +import { unmountTeardown } from '@/lib/search/unmountTeardown'; import type { WindowApplyValue } from '@/lib/search/windowState'; export type SearchPhase = 'idle' | 'loading' | 'done' | 'error'; @@ -448,11 +452,29 @@ export const useSearchRun = () => { return () => document.removeEventListener('visibilitychange', onVisibility); }, []); + // A client-side navigation unmounts the search with its posts in flight: + // each ends as `left` and its stream aborts, so a late answer sends no + // second terminal event. unmountTeardown keeps Strict Mode's dev + // remount from aborting a search a mount effect has just started. + const leaveRef = useRef | null>(null); + useEffect(() => { + const leave = (leaveRef.current ??= unmountTeardown(() => { + baseTrackRef.current?.abandon('left'); + editTrackRef.current?.abandon('left'); + runController.current?.abort(); + postController.current?.controller.abort(); + })); + + leave.mount(); + + return leave.unmount; + }, []); + // The search and the trace behind the answer on screen, read when a // visitor acts on it (`search.action`, the Studio hand-off). Both are // null before a search and on the starting point. const searchIds = useCallback( - () => ({ + (): SearchIds => ({ searchId: searchRef.current?.id ?? null, traceId: searchRef.current?.traceId ?? null, }), diff --git a/src/lib/events/eventLog.ts b/src/lib/events/eventLog.ts new file mode 100644 index 0000000..b058c65 --- /dev/null +++ b/src/lib/events/eventLog.ts @@ -0,0 +1,50 @@ +// The log half of src/lib/events, in its own module (no db or server-only imports) so +// src/lib/test/events-log.test.ts can exercise it under plain node. + +export type EventLogEntry = { + severity: 'INFO'; + message: 'event'; + name: string; + props: Record; + page: string | null; + signedIn: boolean; +}; + +/** + * The log entry for one usage event. It holds the event's name, props and + * page, and whether a user was signed in; it holds no `userId` and no + * `visitorId`, so the log cannot link events to a person. `page` keeps the + * path alone, as the request log line does: feedback sends its page with + * the query string. Props carry the clipped query text, and the entry lives + * as long as the log bucket's retention. + */ +export const eventLogEntry = ( + name: string, + props: Record, + who: { userId?: string | null; page?: string | null }, +): EventLogEntry => ({ + severity: 'INFO', + message: 'event', + name, + props, + page: who.page?.split(/[?#]/, 1)[0] ?? null, + signedIn: Boolean(who.userId), +}); + +/** + * Write the event as one JSON line on stdout, which Cloud Logging stores as + * a structured entry, so an event joins the dfo-apis resolve line by the + * `traceId` in its props. Never throws: a logging fault must not fail the + * request. + */ +export const logEvent = ( + name: string, + props: Record, + who: { userId?: string | null; page?: string | null }, +): void => { + try { + console.log(JSON.stringify(eventLogEntry(name, props, who))); + } catch (error) { + console.error('event log failed', name, error); + } +}; diff --git a/src/lib/events/track.ts b/src/lib/events/track.ts index 91671f5..09369a1 100644 --- a/src/lib/events/track.ts +++ b/src/lib/events/track.ts @@ -2,16 +2,17 @@ import 'server-only'; import { db } from '@/lib/db'; import { event } from '../../../db/schema'; +import { logEvent } from './eventLog'; import { posthogConfigured, sendEventToPosthog } from './posthog'; /** * Usage events (the analytics in DFO Overview → Data access → "Events to - * record"), stored in our own `event` table and, when POSTHOG_KEY and - * POSTHOG_HOST are set, forwarded server-side to PostHog (EU cloud): no - * cookies, no browser-side analytics script, no Studio prompt text. The - * search events hold the first 500 characters of the query. The browser - * posts its events through /api/events (see ./client); the server records - * its own directly. + * record"), written as one JSON log line (see ./eventLog), stored in our own + * `event` table and, when POSTHOG_KEY and POSTHOG_HOST are set, forwarded + * server-side to PostHog (EU cloud): no cookies, no browser-side analytics + * script, no Studio prompt text. The search events hold the first 500 + * characters of the query. The browser posts its events through /api/events + * (see ./client); the server records its own directly. * * Every name the site emits, so a typo can't fork the data: * - search.query an answered search (spec ids, measures and filters, places, @@ -19,7 +20,7 @@ import { posthogConfigured, sendEventToPosthog } from './posthog'; * - search.empty a search the graph couldn't answer (the reasons) * - search.edit an edit to a search answer, re-resolved (its outcome) * - search.error a search post that failed (the error code) - * - search.abandon a search post left in flight (hidden, superseded or reset) + * - search.abandon a search post left in flight (hidden, superseded, reset or left) * - search.action a click on a search answer (pick, download, share, ...) * - search.start_point the starting point shown, with no search call * - search.studio_handoff a Studio action on a search answer @@ -73,9 +74,12 @@ export type Who = { /** * Record one event. Fire-and-forget: analytics never cost a request, so a * failed insert or forward is logged and swallowed, and each sink fails - * independently of the other. + * independently of the others. The log line goes first and cannot throw, so + * Cloud Logging has the event whether the insert or the forward fails. */ export const track = async (name: EventName, props: EventProps, who: Who) => { + logEvent(name, props, who); + try { await db.insert(event).values({ name, diff --git a/src/lib/search/searchEvents.ts b/src/lib/search/searchEvents.ts index 9983068..0c6e7ff 100644 --- a/src/lib/search/searchEvents.ts +++ b/src/lib/search/searchEvents.ts @@ -308,7 +308,10 @@ export const searchEventName = (props: SearchEventProps): 'search.query' | 'sear export type SearchPostEventName = 'search.query' | 'search.empty' | 'search.edit' | 'search.error' | 'search.abandon'; -export type AbandonReason = 'hidden' | 'superseded' | 'reset'; +/** Why a post ended in flight: the page was hidden, a newer post or the + * starting point replaced it, the reader reset the page, or the reader + * navigated away and the search unmounted. */ +export type AbandonReason = 'hidden' | 'superseded' | 'reset' | 'left'; /** The fields of the engine's `done` event a settle reads. */ export type SearchDone = { trace_id: string; elapsed_ms: number; timed_out: boolean }; @@ -456,3 +459,22 @@ export function searchPostTracker( }, }; } + +/** The search and the trace behind the answer on screen, null for each + * before a search and on the starting point. */ +export type SearchIds = { searchId: string | null; traceId: string | null }; + +/** + * Starts a visitor's action on the answer on screen: reads the ids now and + * returns the sender of its `search.action`. A share or a download sends + * once its request lands, when a newer search may be on screen, and the + * event still names the search the visitor acted on. + */ +export function startSearchAction( + searchIds: () => SearchIds, + emit: (name: 'search.action', props: Record) => void, +): (action: TAction) => void { + const ids = searchIds(); + + return (action) => emit('search.action', { ...ids, ...action }); +} diff --git a/src/lib/search/test/searchEvents.test.ts b/src/lib/search/test/searchEvents.test.ts index 4f148a0..54bc75f 100644 --- a/src/lib/search/test/searchEvents.test.ts +++ b/src/lib/search/test/searchEvents.test.ts @@ -7,6 +7,8 @@ import { searchEventProps, searchOutcome, searchPostTracker, + startSearchAction, + type SearchIds, type SearchPostEventName, type SearchSettleProps, } from '../searchEvents.ts'; @@ -481,14 +483,18 @@ test('a hidden post sends one hidden abandon and marks its later settle', () => ); }); -test('a settled post ignores a later hide, and a superseded one abandons once', () => { +test('a settled post ignores a later hide, and an abandoned one sends nothing more', () => { const settled = tracked(); const superseded = tracked(1); + const left = tracked(); settled.tracker.settle({ response: response('catalogue'), parts: [], done }); settled.tracker.hide(); superseded.tracker.abandon('superseded'); superseded.tracker.abandon('reset'); + // The search unmounted, then its stream's answer landed. + left.tracker.abandon('left'); + left.tracker.settle({ response: response('catalogue'), parts: [], done }); assert.deepEqual( settled.sent.map(([name]) => name), @@ -498,6 +504,29 @@ test('a settled post ignores a later hide, and a superseded one abandons once', superseded.sent.map(([name, props]) => [name, props.reason, props.traceId]), [['search.abandon', 'superseded', null]], ); + assert.deepEqual( + left.sent.map(([name, props]) => [name, props.reason]), + [['search.abandon', 'left']], + ); +}); + +test('an action names the search on screen when it started, not when it landed', () => { + const sent: [string, Record][] = []; + let onScreen: SearchIds = { searchId: 'search-a', traceId: 'trace-a' }; + const send = startSearchAction<{ action: 'share'; shareId: string }>( + () => onScreen, + (name, props) => sent.push([name, props]), + ); + + onScreen = { searchId: 'search-b', traceId: 'trace-b' }; + send({ action: 'share', shareId: 'share-1' }); + + assert.deepEqual(sent, [ + [ + 'search.action', + { searchId: 'search-a', traceId: 'trace-a', action: 'share', shareId: 'share-1' }, + ], + ]); }); test('a failure carries its code, else its HTTP status, and the trace it names', () => { diff --git a/src/lib/search/test/unmountTeardown.test.ts b/src/lib/search/test/unmountTeardown.test.ts new file mode 100644 index 0000000..edb6f97 --- /dev/null +++ b/src/lib/search/test/unmountTeardown.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { unmountTeardown } from '../unmountTeardown.ts'; + +/** A teardown over a manual queue, and how many times it ran. */ +const lifecycle = () => { + const queue: (() => void)[] = []; + let runs = 0; + const guard = unmountTeardown( + () => runs++, + (run) => queue.push(run), + ); + + return { + guard, + flush: () => queue.splice(0).forEach((run) => run()), + runs: () => runs, + }; +}; + +test('a Strict Mode unmount and remount in one pass tears nothing down', () => { + const { guard, flush, runs } = lifecycle(); + + guard.mount(); + guard.unmount(); + guard.mount(); + flush(); + + assert.equal(runs(), 0); +}); + +test('a real unmount tears down once, after the pass that unmounted it', () => { + const { guard, flush, runs } = lifecycle(); + + guard.mount(); + guard.unmount(); + guard.mount(); + guard.unmount(); + + assert.equal(runs(), 0); + + flush(); + flush(); + + assert.equal(runs(), 1); +}); diff --git a/src/lib/search/unmountTeardown.ts b/src/lib/search/unmountTeardown.ts new file mode 100644 index 0000000..8146db2 --- /dev/null +++ b/src/lib/search/unmountTeardown.ts @@ -0,0 +1,40 @@ +/** + * Runs `teardown` once a component has unmounted for good. Call `mount()` + * from an effect's setup and `unmount()` from its cleanup. + * + * React Strict Mode unmounts and remounts every component once in + * development, running the effect cleanups and setups back to back in one + * synchronous pass. A teardown run from the cleanup itself would end work a + * mount effect had just started, such as a `?q=` permalink's search, which + * its own ref guard then never restarts. `unmount()` defers the teardown by + * a microtask and runs it only if no `mount()` came in between. + */ +export function unmountTeardown( + teardown: () => void, + defer: (run: () => void) => void = queueMicrotask, +): { mount: () => void; unmount: () => void } { + let mounted = false; + let pending = false; + + return { + mount() { + mounted = true; + }, + unmount() { + mounted = false; + + if (pending) { + return; + } + + pending = true; + defer(() => { + pending = false; + + if (!mounted) { + teardown(); + } + }); + }, + }; +} diff --git a/src/lib/test/events-log.test.ts b/src/lib/test/events-log.test.ts new file mode 100644 index 0000000..24238dc --- /dev/null +++ b/src/lib/test/events-log.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { logEvent } from '../events/eventLog.ts'; + +// Replaces console.log and console.error for one call; returns what each received. +const capture = (run: () => void) => { + const realLog = console.log; + const realError = console.error; + const logged: string[] = []; + const errors: unknown[][] = []; + console.log = (line: string) => logged.push(line); + console.error = (...args: unknown[]) => errors.push(args); + + try { + run(); + } finally { + console.log = realLog; + console.error = realError; + } + + return { logged, errors }; +}; + +test('one JSON line carries the name, props, page and signed-in flag, and no id', () => { + // The whole Who, as track() passes it: the log takes only the user's presence and the page. + const who = { userId: 'u-456', visitorId: 'v-123', page: '/data', headers: null }; + const { logged } = capture(() => + logEvent('search.query', { traceId: 'abc123', query: 'aid to Kenya' }, who), + ); + + assert.equal(logged.length, 1); + assert.deepEqual(JSON.parse(logged[0]), { + severity: 'INFO', + message: 'event', + name: 'search.query', + props: { traceId: 'abc123', query: 'aid to Kenya' }, + page: '/data', + signedIn: true, + }); + assert.ok(!logged[0].includes('u-456') && !logged[0].includes('v-123')); +}); + +test('the page keeps its path and drops a query string or fragment', () => { + const pages = ['/roadmap?tab=requests#new', '/data?q=aid%20to%20Kenya', '/docs#events']; + const { logged } = capture(() => { + for (const page of pages) { + logEvent('feedback.submit', {}, { page }); + } + }); + + assert.deepEqual( + logged.map((line) => JSON.parse(line).page), + ['/roadmap', '/data', '/docs'], + ); +}); + +test('an anonymous event with no page logs signedIn false and a null page', () => { + const { logged } = capture(() => logEvent('embed.view', {}, {})); + + const entry = JSON.parse(logged[0]); + assert.equal(entry.signedIn, false); + assert.equal(entry.page, null); +}); + +test('props that cannot be serialised are reported and do not throw', () => { + const circular: Record = {}; + circular.self = circular; + + const { logged, errors } = capture(() => logEvent('search.query', circular, {})); + + assert.equal(logged.length, 0); + assert.equal(errors.length, 1); +}); From e5783d3f24d5c9c0136f890bdd759570943b4bf3 Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 15 Oct 2026 11:46:34 +0200 Subject: [PATCH 6/7] fix(search): record a search post's start page on its events 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. --- .../organisms/DataSearch/DataSearch.tsx | 2 +- .../organisms/DataSearch/useSearchRun.ts | 1 + src/lib/events/client.ts | 10 ++++-- src/lib/search/searchEvents.ts | 33 ++++++++++------- src/lib/search/test/searchEvents.test.ts | 36 ++++++++++++++++--- 5 files changed, 62 insertions(+), 20 deletions(-) diff --git a/src/components/organisms/DataSearch/DataSearch.tsx b/src/components/organisms/DataSearch/DataSearch.tsx index 0764a6d..40aacaa 100644 --- a/src/components/organisms/DataSearch/DataSearch.tsx +++ b/src/components/organisms/DataSearch/DataSearch.tsx @@ -320,7 +320,7 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { // A visitor's action on the answer on screen (docs/analytics.md): the // ids are read as the visitor acts, and the returned sender records it. const startAction = useCallback( - () => startSearchAction(searchIds, track), + () => startSearchAction(searchIds, track, location.pathname), [searchIds], ); diff --git a/src/components/organisms/DataSearch/useSearchRun.ts b/src/components/organisms/DataSearch/useSearchRun.ts index 47d110a..a6f253c 100644 --- a/src/components/organisms/DataSearch/useSearchRun.ts +++ b/src/components/organisms/DataSearch/useSearchRun.ts @@ -433,6 +433,7 @@ export const useSearchRun = () => { source: search.source, editKind, query, + page: location.pathname, }, { emit: track, now: () => performance.now() }, ); diff --git a/src/lib/events/client.ts b/src/lib/events/client.ts index d37c828..f58a6e7 100644 --- a/src/lib/events/client.ts +++ b/src/lib/events/client.ts @@ -56,8 +56,14 @@ const visitorId = (): string | null => { /** * Record a usage event from the browser (POST /api/events). Fire-and-forget * with `keepalive`, so an event sent on the way out of a page still lands. + * `page` defaults to the current path; an event sent after a navigation passes + * the page it describes. */ -export const track = (name: EventName, props: EventProps = {}) => { +export const track = ( + name: EventName, + props: EventProps = {}, + page: string = location.pathname, +) => { try { void fetch('/api/events', { method: 'POST', @@ -66,7 +72,7 @@ export const track = (name: EventName, props: EventProps = {}) => { name, props, visitorId: visitorId(), - page: location.pathname, + page, }), keepalive: true, }).catch(() => undefined); diff --git a/src/lib/search/searchEvents.ts b/src/lib/search/searchEvents.ts index 0c6e7ff..3a5f75f 100644 --- a/src/lib/search/searchEvents.ts +++ b/src/lib/search/searchEvents.ts @@ -341,12 +341,14 @@ export function searchPostTracker( source: SearchSource; editKind: EditKind | null; query: string; + /** The page the post started on; a `left` abandon runs after the URL has changed. */ + page: string; }, { emit, now, }: { - emit: (name: SearchPostEventName, props: Record) => void; + emit: (name: SearchPostEventName, props: Record, page: string) => void; now: () => number; }, ) { @@ -383,12 +385,16 @@ export function searchPostTracker( } traceId = failedTrace ?? traceId; - emit('search.error', { - ...runProps(), - query: clip(post.query, MAX_QUERY), - error: code ?? (status != null ? String(status) : 'stream'), - ...(status != null && { status }), - }); + emit( + 'search.error', + { + ...runProps(), + query: clip(post.query, MAX_QUERY), + error: code ?? (status != null ? String(status) : 'stream'), + ...(status != null && { status }), + }, + post.page, + ); }; return { @@ -439,7 +445,7 @@ export function searchPostTracker( }, }); - emit(edited ? 'search.edit' : searchEventName(props), props); + emit(edited ? 'search.edit' : searchEventName(props), props, post.page); }, fail, abandon(reason: Exclude) { @@ -447,14 +453,14 @@ export function searchPostTracker( return; } - emit('search.abandon', { ...runProps(), reason, step }); + emit('search.abandon', { ...runProps(), reason, step }, post.page); }, hide() { if (ended || hidden) { return; } - emit('search.abandon', { ...runProps(), reason: 'hidden', step }); + emit('search.abandon', { ...runProps(), reason: 'hidden', step }, post.page); hidden = true; }, }; @@ -468,13 +474,14 @@ export type SearchIds = { searchId: string | null; traceId: string | null }; * Starts a visitor's action on the answer on screen: reads the ids now and * returns the sender of its `search.action`. A share or a download sends * once its request lands, when a newer search may be on screen, and the - * event still names the search the visitor acted on. + * event still names the search the visitor acted on, and the page it was on. */ export function startSearchAction( searchIds: () => SearchIds, - emit: (name: 'search.action', props: Record) => void, + emit: (name: 'search.action', props: Record, page: string) => void, + page: string, ): (action: TAction) => void { const ids = searchIds(); - return (action) => emit('search.action', { ...ids, ...action }); + return (action) => emit('search.action', { ...ids, ...action }, page); } diff --git a/src/lib/search/test/searchEvents.test.ts b/src/lib/search/test/searchEvents.test.ts index 54bc75f..dfc3818 100644 --- a/src/lib/search/test/searchEvents.test.ts +++ b/src/lib/search/test/searchEvents.test.ts @@ -373,7 +373,7 @@ test('the outcome is a resolution status, else the answer kind', () => { /** A tracker over a fake clock, and the events it sent. */ const tracked = (post = 0) => { - const sent: [SearchPostEventName, Record][] = []; + const sent: [SearchPostEventName, Record, string][] = []; let clock = 1000; const tracker = searchPostTracker( { @@ -382,8 +382,9 @@ const tracked = (post = 0) => { source: 'example', editKind: post > 0 ? 'slot' : null, query: 'ODA to Kenya', + page: '/data', }, - { emit: (name, props) => sent.push([name, props]), now: () => clock }, + { emit: (name, props, page) => sent.push([name, props, page]), now: () => clock }, ); return { tracker, sent, advance: (ms: number) => (clock += ms) }; @@ -510,12 +511,38 @@ test('a settled post ignores a later hide, and an abandoned one sends nothing mo ); }); +test('every event of a post carries the page it started on, after the page changed', () => { + const left = tracked(); + const hidden = tracked(); + const settled = tracked(); + const failed = tracked(); + + // The tracker holds the start page, so nothing it reads can have moved on. + left.tracker.abandon('left'); + hidden.tracker.hide(); + settled.tracker.settle({ response: response('catalogue'), parts: [], done }); + failed.tracker.fail({}); + + assert.deepEqual( + [left, hidden, settled, failed].map(({ sent }) => + sent.map(([name, , page]) => [name, page]), + ), + [ + [['search.abandon', '/data']], + [['search.abandon', '/data']], + [['search.query', '/data']], + [['search.error', '/data']], + ], + ); +}); + test('an action names the search on screen when it started, not when it landed', () => { - const sent: [string, Record][] = []; + const sent: [string, Record, string][] = []; let onScreen: SearchIds = { searchId: 'search-a', traceId: 'trace-a' }; const send = startSearchAction<{ action: 'share'; shareId: string }>( () => onScreen, - (name, props) => sent.push([name, props]), + (name, props, page) => sent.push([name, props, page]), + '/data', ); onScreen = { searchId: 'search-b', traceId: 'trace-b' }; @@ -525,6 +552,7 @@ test('an action names the search on screen when it started, not when it landed', [ 'search.action', { searchId: 'search-a', traceId: 'trace-a', action: 'share', shareId: 'share-1' }, + '/data', ], ]); }); From 41b8e2afbd36a72292b8d1f963c5452cde8018ef Mon Sep 17 00:00:00 2001 From: Jorge Rivera Date: Thu, 15 Oct 2026 13:18:20 +0200 Subject: [PATCH 7/7] fix(search): stop searches after leaving /data; keep homepage source 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. --- docs/analytics.md | 2 +- .../organisms/DataSearch/DataSearch.tsx | 29 +++++++++++--- .../organisms/DataSearch/useSearchRun.ts | 38 +++++++++++++------ .../DownloadDataset/DownloadDataset.tsx | 3 +- src/lib/events/client.ts | 3 +- src/lib/search/searchEvents.ts | 14 +++++++ src/lib/search/test/searchEvents.test.ts | 19 ++++++++++ src/lib/search/test/unmountTeardown.test.ts | 7 ++++ src/lib/search/unmountTeardown.ts | 10 ++++- 9 files changed, 104 insertions(+), 21 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index f5fa60b..f48ee4e 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -32,7 +32,7 @@ Every search post, the base run and each edit after it, ends on one terminal eve - `searchId`: a UUID minted when a search starts (a query, a permalink, a share or version link), kept by its edit posts. - `post`: 0 for the base run, then 1, 2, … for each edit post of the same search. -- `source`: where the search came from: `typed`, `example` (a suggestion or example chip), `catalogue` (the starting point), `refine`, `retry`, `permalink` (`?q=`), `share` (`/s/`) or `version_link` (`?spec=`). +- `source`: where the search came from: `typed`, `example` (a suggestion or example chip), `catalogue` (the starting point), `refine`, `retry`, `permalink` (a shared or bookmarked `?q=` link), `share` (`/s/`) or `version_link` (`?spec=`). A search typed or picked on the homepage reaches `/data` with `?q=` and keeps `typed` or `example`. - `edited` (true from post 1) and `editKind` (`slot`, `entity`, `window`, `facet`, `pick`, `measure`, `parsed`, `shared`, or `replay` for a permalink's stored edits). - `traceId`: the engine's trace id, the `trace_id` on the dfo-apis `resolve` log line, null when the post ended before the engine answered. - `ms`: client time from the post's start to the event. diff --git a/src/components/organisms/DataSearch/DataSearch.tsx b/src/components/organisms/DataSearch/DataSearch.tsx index 40aacaa..5eff5b4 100644 --- a/src/components/organisms/DataSearch/DataSearch.tsx +++ b/src/components/organisms/DataSearch/DataSearch.tsx @@ -56,7 +56,13 @@ import { snapshotLiveNote, studioHandoffParts, } from '@/lib/search/resultNotes'; -import { searchEventProps, startSearchAction, type SearchSource } from '@/lib/search/searchEvents'; +import { + HANDOFF_SOURCE_PARAM, + handoffSource, + searchEventProps, + startSearchAction, + type SearchSource, +} from '@/lib/search/searchEvents'; import { earlySentenceTokens, tokenPart } from '@/lib/search/sentencePlan'; import { handoffEdited, @@ -178,8 +184,7 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { const runQuery = useCallback( (text: string, source: SearchSource) => { if (text === STARTING_POINT_QUERY) { - showStartingPoint(text); - track('search.start_point', { source }); + showStartingPoint(text, source); } else { run({ kind: 'raw_text', query: text }, text, source); } @@ -476,6 +481,9 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { ); // Permalink / homepage handoff: ?q= (and ?s=) auto-runs once per value. + // The homepage prompt adds `via` (typed or example). It is read here and + // stripped at once, so the address bar never offers it for copying and a + // pasted link records `permalink`. useEffect(() => { const q = searchParams.get('q'); const s = searchParams.get('s') ?? ''; @@ -487,8 +495,17 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { startedRef.current = key; + const source = handoffSource(searchParams.get(HANDOFF_SOURCE_PARAM)); + + if (searchParams.has(HANDOFF_SOURCE_PARAM)) { + const rest = new URLSearchParams(searchParams); + + rest.delete(HANDOFF_SOURCE_PARAM); + window.history.replaceState(null, '', `/data?${rest}`); + } + if (!s) { - runRef.current(q, 'permalink'); + runRef.current(q, source); return; } @@ -509,9 +526,9 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { // URL-sync effect below must not read that transient empty // state as "no edits" and strip `s=` before they land. suppressEmptyEditWriteRef.current = true; - replayRef.current(state, 'permalink'); + replayRef.current(state, source); } else { - runRef.current(q, 'permalink'); + runRef.current(q, source); } }); // `run` and `replay` are deliberately absent. Watching either would diff --git a/src/components/organisms/DataSearch/useSearchRun.ts b/src/components/organisms/DataSearch/useSearchRun.ts index a6f253c..4163ec4 100644 --- a/src/components/organisms/DataSearch/useSearchRun.ts +++ b/src/components/organisms/DataSearch/useSearchRun.ts @@ -76,8 +76,9 @@ * for permalink.ts's encodePermalinkState. * - `run(input, label, source)`: a fresh raw_text query; full replace, * aborts any edit post in flight too. - * - `showStartingPoint(label)`: settles on a catalogue answer with no place - * and no search call, for the page to fill from the catalogue it holds. + * - `showStartingPoint(label, source)`: settles on a catalogue answer with + * no place and no search call, for the page to fill from the catalogue it + * holds, and records `search.start_point`. * - `reset()`: abort everything, back to idle. * Indexed edit callbacks. Each posts a dataset_resubmit carrying every * part's current entry with just this index rewritten. editMeasure posts @@ -134,7 +135,8 @@ * aborted by `reset` abandons as `reset`. A client-side navigation that * unmounts the search abandons each post in flight as `left` and aborts its * stream. The tracker sends only the first of these, so each post sends one - * terminal event. A page hidden mid-post sends a `hidden` + * terminal event. After that unmount every run start refuses, so a link + * lookup that resolves late starts no search and sends nothing. A page hidden mid-post sends a `hidden` * abandon from the visibility listener below. `searchIds()` reads the * current search's id and the trace of the answer on screen, for the events * a visitor's click on that answer sends. @@ -143,6 +145,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { track } from '@/lib/events/client'; +import { uuid } from '@/lib/uuid'; import { withEntityEdit, withFacetEdit, withResolvedSlotEdit } from '@/lib/search/promote'; import type { PermalinkEdit, PermalinkState } from '@/lib/search/permalink'; @@ -420,7 +423,7 @@ export const useSearchRun = () => { // first, and an edit needs a run's parts, so the fallback never mints. const trackPost = useCallback((editKind: EditKind | null, query: string) => { const search = (searchRef.current ??= { - id: crypto.randomUUID(), + id: uuid(), source: 'typed', posts: 0, traceId: null, @@ -456,7 +459,9 @@ export const useSearchRun = () => { // A client-side navigation unmounts the search with its posts in flight: // each ends as `left` and its stream aborts, so a late answer sends no // second terminal event. unmountTeardown keeps Strict Mode's dev - // remount from aborting a search a mount effect has just started. + // remount from aborting a search a mount effect has just started. Once + // it has run, `startRun` and `showStartingPoint` refuse, so a link + // lookup that resolves after the visitor has gone starts nothing. const leaveRef = useRef | null>(null); useEffect(() => { const leave = (leaveRef.current ??= unmountTeardown(() => { @@ -760,7 +765,11 @@ export const useSearchRun = () => { label: string, source: SearchSource, onResponse?: (normalized: Resolution) => void, - ) => { + ): boolean => { + if (leaveRef.current?.left()) { + return false; + } + // A fresh query replaces everything: abort the previous full run // and any edit post in flight. baseTrackRef.current?.abandon('superseded'); @@ -778,7 +787,7 @@ export const useSearchRun = () => { runController.current = controller; runOp.current = op; - searchRef.current = { id: crypto.randomUUID(), source, posts: 0, traceId: null }; + searchRef.current = { id: uuid(), source, posts: 0, traceId: null }; const tracker = trackPost(null, label); @@ -806,6 +815,8 @@ export const useSearchRun = () => { setUnreadableRefs(new Set()); runBaseStream(op, controller, input, tracker, onResponse); + + return true; }, [runBaseStream, trackPost], ); @@ -860,9 +871,13 @@ export const useSearchRun = () => { }, []); /** The starting point with no search call: a settled catalogue answer - * with no place, under `label`. */ + * with no place, under `label`. Records `search.start_point`. */ const showStartingPoint = useCallback( - (label: string) => { + (label: string, source: SearchSource) => { + if (leaveRef.current?.left()) { + return; + } + // The reader moved to another view: end any post in flight as // superseded before reset() would send `reset`. The tracker // sends only the first terminal event. @@ -872,6 +887,7 @@ export const useSearchRun = () => { setPhase('done'); setQuery(label); setAnswer({ kind: 'catalogue', scope: { place: null } }); + track('search.start_point', { source }); }, [reset], ); @@ -1250,7 +1266,7 @@ export const useSearchRun = () => { // frozen observations through that ref, whatever spec_id comes back. // A version link has no question; the wire refuses an empty // raw_query. - startRun( + const started = startRun( { kind: 'dataset_resubmit', entries, @@ -1264,7 +1280,7 @@ export const useSearchRun = () => { : undefined, ); - if (shared.mode !== 'snapshot') { + if (!started || shared.mode !== 'snapshot') { return; } diff --git a/src/components/organisms/DownloadDataset/DownloadDataset.tsx b/src/components/organisms/DownloadDataset/DownloadDataset.tsx index ad7df33..05f12b0 100644 --- a/src/components/organisms/DownloadDataset/DownloadDataset.tsx +++ b/src/components/organisms/DownloadDataset/DownloadDataset.tsx @@ -17,6 +17,7 @@ import { roundedCount, type SearchCatalogue, } from '@/lib/search/catalogue'; +import { handoffHref } from '@/lib/search/searchEvents'; // `catalogue` is the search catalogue the homepage loaded on the server, or // null: its examples are the chips, its counts the tiles and the indicator @@ -71,7 +72,7 @@ const DownloadDataset = ({ catalogue }: { catalogue: SearchCatalogue | null }) =

    router.push(`/data?q=${encodeURIComponent(query)}`)} + onSubmit={(query, via) => router.push(handoffHref(query, via))} />
    {tiles.length > 0 && ( diff --git a/src/lib/events/client.ts b/src/lib/events/client.ts index f58a6e7..0c42428 100644 --- a/src/lib/events/client.ts +++ b/src/lib/events/client.ts @@ -1,6 +1,7 @@ 'use client'; import { hasServiceConsent, onServiceConsent, USAGE_STATS_SERVICE } from '@/lib/consent'; +import { uuid } from '@/lib/uuid'; import type { EventName, EventProps } from './track'; @@ -43,7 +44,7 @@ const visitorId = (): string | null => { let id = localStorage.getItem(VISITOR_KEY); if (!id) { - id = crypto.randomUUID(); + id = uuid(); localStorage.setItem(VISITOR_KEY, id); } diff --git a/src/lib/search/searchEvents.ts b/src/lib/search/searchEvents.ts index 3a5f75f..15d56ec 100644 --- a/src/lib/search/searchEvents.ts +++ b/src/lib/search/searchEvents.ts @@ -50,6 +50,20 @@ type Rung = 'full' | 'sampled' | 'minimal' | 'floor'; export type SearchSource = 'typed' | 'example' | 'catalogue' | 'refine' | 'retry' | 'permalink' | 'share' | 'version_link'; +/** The `/data` query param that carries a prompt's source from another page. + * `/data` reads it once and strips it from the URL before the search + * settles, so a copied link holds `?q=` alone and records `permalink`. */ +export const HANDOFF_SOURCE_PARAM = 'via'; + +/** The `/data` URL a prompt on another page sends its query to. */ +export const handoffHref = (query: string, via: 'typed' | 'example'): string => + `/data?q=${encodeURIComponent(query)}&${HANDOFF_SOURCE_PARAM}=${via}`; + +/** The source of a `?q=` arrival: the handoff's own `typed` or `example`, + * and `permalink` for anything else, a missing param included. */ +export const handoffSource = (via: string | null): SearchSource => + via === 'typed' || via === 'example' ? via : 'permalink'; + /** The useSearchRun callback that posted an edit. `replay` is a permalink's * stored edits, posted once after its base run lands. */ export type EditKind = diff --git a/src/lib/search/test/searchEvents.test.ts b/src/lib/search/test/searchEvents.test.ts index dfc3818..7eac67d 100644 --- a/src/lib/search/test/searchEvents.test.ts +++ b/src/lib/search/test/searchEvents.test.ts @@ -4,6 +4,9 @@ import test from 'node:test'; import { fitsProps, MAX_PROPS_BYTES } from '../../events/limits.ts'; import { + HANDOFF_SOURCE_PARAM, + handoffHref, + handoffSource, searchEventProps, searchOutcome, searchPostTracker, @@ -585,3 +588,19 @@ test('a failure carries its code, else its HTTP status, and the trace it names', ], ); }); + +test('a homepage handoff carries typed or example; any other value is a permalink', () => { + const arrival = (href: string) => + handoffSource(new URL(href, 'https://example.org').searchParams.get(HANDOFF_SOURCE_PARAM)); + + assert.equal(arrival(handoffHref('aid & debt', 'typed')), 'typed'); + assert.equal(arrival(handoffHref('aid & debt', 'example')), 'example'); + assert.equal( + new URL(handoffHref('aid & debt', 'typed'), 'https://example.org').searchParams.get('q'), + 'aid & debt', + ); + + for (const href of ['/data?q=aid', '/data?q=aid&via=share', '/data?q=aid&via=Typed']) { + assert.equal(arrival(href), 'permalink'); + } +}); diff --git a/src/lib/search/test/unmountTeardown.test.ts b/src/lib/search/test/unmountTeardown.test.ts index edb6f97..016e265 100644 --- a/src/lib/search/test/unmountTeardown.test.ts +++ b/src/lib/search/test/unmountTeardown.test.ts @@ -28,6 +28,7 @@ test('a Strict Mode unmount and remount in one pass tears nothing down', () => { flush(); assert.equal(runs(), 0); + assert.equal(guard.left(), false); }); test('a real unmount tears down once, after the pass that unmounted it', () => { @@ -39,9 +40,15 @@ test('a real unmount tears down once, after the pass that unmounted it', () => { guard.unmount(); assert.equal(runs(), 0); + assert.equal(guard.left(), false); flush(); flush(); assert.equal(runs(), 1); + assert.equal(guard.left(), true); + + guard.mount(); + + assert.equal(guard.left(), false); }); diff --git a/src/lib/search/unmountTeardown.ts b/src/lib/search/unmountTeardown.ts index 8146db2..1f58d5b 100644 --- a/src/lib/search/unmountTeardown.ts +++ b/src/lib/search/unmountTeardown.ts @@ -8,17 +8,23 @@ * mount effect had just started, such as a `?q=` permalink's search, which * its own ref guard then never restarts. `unmount()` defers the teardown by * a microtask and runs it only if no `mount()` came in between. + * + * `left()` is true from the teardown until the next `mount()`, so async work + * that resolves after the component has gone can refuse to start more. A + * Strict Mode remount cancels the teardown and never sets it. */ export function unmountTeardown( teardown: () => void, defer: (run: () => void) => void = queueMicrotask, -): { mount: () => void; unmount: () => void } { +): { mount: () => void; unmount: () => void; left: () => boolean } { let mounted = false; let pending = false; + let left = false; return { mount() { mounted = true; + left = false; }, unmount() { mounted = false; @@ -32,9 +38,11 @@ export function unmountTeardown( pending = false; if (!mounted) { + left = true; teardown(); } }); }, + left: () => left, }; }