diff --git a/docs/analytics.md b/docs/analytics.md index 99ec3f0..73e3223 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -1,30 +1,65 @@ # 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 -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 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, 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 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` | | `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) | +| `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 | | `gallery.open` | a click, middle-click or Enter on a tile in the `/insights/gallery` grid | `slug` (the item's slug), `kind` (`conversation` / `canvas` / `card` / `dataset`) | +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. +- `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. +- `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 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') + or (t.name = 'search.abandon' and t.props->>'reason' <> 'hidden')) + 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. -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. +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: @@ -47,11 +82,11 @@ 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. -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, 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/app/(site)/ai-policy/page.tsx b/src/app/(site)/ai-policy/page.tsx index 32a6548..61c690b 100644 --- a/src/app/(site)/ai-policy/page.tsx +++ b/src/app/(site)/ai-policy/page.tsx @@ -84,8 +84,9 @@ 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. 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/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..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(); @@ -79,7 +95,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..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). @@ -41,8 +36,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/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/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 76f7ae0..f3d5a98 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 { fetchSearchShare, fetchVersionLink, type OpenedSearchShare, + type SearchShareMode, type VersionLinkResult, } from '@/lib/search/searchClient'; import { searchSharePart } from '@/lib/search/searchShareRequest'; @@ -54,7 +56,13 @@ import { snapshotLiveNote, studioHandoffParts, } from '@/lib/search/resultNotes'; -import { searchEventName, searchEventProps } 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, @@ -111,6 +119,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 +166,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 @@ -166,16 +181,16 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { const [suggestionSlot] = useState(createSuggestionSlot); // 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. Every - // run, typed or opened from a link, passes here, so a live suggestion - // stream for other text stops and one for the same text answers the run. + // the page holds, with no search call, and records `search.start_point`. + // Other text is a search, whose events useSearchRun sends. Every run + // keeps only the live suggestion stream for the same text. const runQuery = useCallback( - (text: string) => { + (text: string, source: SearchSource) => { suggestionSlot.keepOnly(text); if (text === STARTING_POINT_QUERY) { - showStartingPoint(text); + showStartingPoint(text, source); } else { - run({ kind: 'raw_text', query: text }, text); + run({ kind: 'raw_text', query: text }, text, source); } }, [suggestionSlot, run, showStartingPoint], @@ -241,16 +256,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], ); @@ -260,9 +275,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]); @@ -284,6 +299,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', @@ -294,10 +310,28 @@ 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): the + // ids are read as the visitor acts, and the returned sender records it. + const startAction = useCallback( + () => startSearchAction(searchIds, track, location.pathname), + [searchIds], + ); // Live suggestions: a debounced background resolve renders interpretation // cards under the prompt while the user is still typing. It posts the @@ -380,7 +414,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 @@ -419,7 +453,7 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { } if (result.kind === 'opened') { - enterShare(result.shared); + enterShare(result.shared, 'version_link'); } else { setLinkRefusal({ link, result }); } @@ -439,7 +473,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); @@ -448,6 +482,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') ?? ''; @@ -459,8 +496,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); + runRef.current(q, source); return; } @@ -481,9 +527,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, source); } else { - runRef.current(q); + runRef.current(q, source); } }); // `run` and `replay` are deliberately absent. Watching either would @@ -581,34 +627,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>(() => {}); @@ -755,12 +773,23 @@ 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), - }).then((result) => ('id' in result ? { ...result, datasets: sharedDatasets } : result)); + }).then((result) => { + if (!('id' in result)) { + return result; + } + + 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 @@ -938,7 +967,10 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { } leaveShare(); - openShare({ ...openedShare, mode: 'live' }); + openShare( + { ...openedShare, mode: 'live' }, + 'share', + ); }} > View latest @@ -975,9 +1007,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} /> @@ -1024,7 +1058,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} @@ -1075,6 +1109,17 @@ const DataSearch = ({ catalogue }: { catalogue: SearchCatalogue | null }) => { note={downloadNote(definiteParts[0].part?.warnings)} createShare={createShare} onStudio={studioDeparture ? undefined : openStudio} + onDownloadStart={() => { + const sendAction = startAction(); + const partIndex = definiteParts[0].index; + + return (downloaded) => + sendAction({ + action: 'download', + partIndex, + ...downloaded, + }); + }} /> )} @@ -1101,8 +1146,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) => { + startAction()({ + 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..7ca5533 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, + onDownloadStart, }: { 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; + // 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 @@ -71,6 +74,7 @@ const SearchPanel = ({ specId: spec?.spec_id, definition: spec?.definition, entities: spec?.entities, + onDownloadStart, }); const rowCount = downloadRowCount(spec); diff --git a/src/components/organisms/DataSearch/useDatasetDownload.ts b/src/components/organisms/DataSearch/useDatasetDownload.ts index 4516bda..ba117c9 100644 --- a/src/components/organisms/DataSearch/useDatasetDownload.ts +++ b/src/components/organisms/DataSearch/useDatasetDownload.ts @@ -98,15 +98,27 @@ 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 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. */ +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, + onDownloadStart, +}: DatasetDownloadTarget) => { const choices = useSyncExternalStore(subscribeChoices, readChoices, serverChoices); const [optionsOpen, setOptionsOpen] = useState(false); const [downloading, setDownloading] = useState(false); @@ -227,6 +239,7 @@ export const useDatasetDownload = ({ specId, definition, entities }: DatasetDown } const controller = new AbortController(); + const downloaded = onDownloadStart?.(); downloadAbortRef.current = controller; setDownloading(true); @@ -308,6 +321,7 @@ export const useDatasetDownload = ({ specId, definition, entities }: DatasetDown } window.location.assign(artifactHref(artifact.url)); + 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 0905c49..4163ec4 100644 --- a/src/components/organisms/DataSearch/useSearchRun.ts +++ b/src/components/organisms/DataSearch/useSearchRun.ts @@ -74,10 +74,11 @@ * 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. - * - `showStartingPoint(label)`: settles on a catalogue answer with no place - * and no search call, for the page to fill from the catalogue it holds. + * - `run(input, label, source)`: a fresh raw_text query; full replace, + * aborts any edit post in flight too. + * - `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 @@ -85,7 +86,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 +124,28 @@ * 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`. 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. 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. */ -import { useCallback, useMemo, useRef, useState } from 'react'; +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'; @@ -186,7 +206,15 @@ import type { Spec, SpecObservations, } from '@/lib/search/types'; +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'; @@ -266,6 +294,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 +397,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 +418,75 @@ 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: uuid(), + source: 'typed', + posts: 0, + traceId: null, + }); + + return searchPostTracker( + { + searchId: search.id, + post: editKind ? ++search.posts : 0, + source: search.source, + editKind, + query, + page: location.pathname, + }, + { 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); + }, []); + + // 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. 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(() => { + 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, + }), + [], + ); + const updateRuntime = useCallback( (index: number, fn: (runtime: PartRuntime) => PartRuntime) => { setRuntimes((prev) => { @@ -497,9 +625,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 +649,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 +676,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 +717,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 +737,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 +763,17 @@ export const useSearchRun = () => { ( input: Extract, 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'); + editTrackRef.current?.abandon('superseded'); runController.current?.abort(); postController.current?.controller.abort(); postController.current = null; @@ -633,6 +787,11 @@ export const useSearchRun = () => { runController.current = controller; runOp.current = op; + searchRef.current = { id: uuid(), source, posts: 0, traceId: null }; + + const tracker = trackPost(null, label); + + baseTrackRef.current = tracker; setPhase('loading'); setQuery(label); @@ -655,22 +814,31 @@ export const useSearchRun = () => { setPendingSnapshotSpecs(new Set()); setUnreadableRefs(new Set()); - runBaseStream(op, controller, input, onResponse); + runBaseStream(op, controller, input, tracker, onResponse); + + return true; }, - [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; @@ -703,13 +871,23 @@ 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. + baseTrackRef.current?.abandon('superseded'); + editTrackRef.current?.abandon('superseded'); reset(); setPhase('done'); setQuery(label); setAnswer({ kind: 'catalogue', scope: { place: null } }); + track('search.start_point', { source }); }, [reset], ); @@ -722,8 +900,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 +952,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 +961,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 +990,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 +1008,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 +1034,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 +1058,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 +1081,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 +1110,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 +1122,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 +1137,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 +1169,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 +1179,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 +1198,7 @@ export const useSearchRun = () => { ), })), picks.map(({ partIndex }) => partIndex), + 'measure', ); }, [parts, runDatasetEdit], @@ -1004,9 +1208,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 +1225,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,27 +1259,28 @@ 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 // 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, ...(shared.query ? { raw_query: shared.query } : {}), }, shared.query, + source, shared.mode === 'snapshot' ? (resolution) => setSnapshotBindings(bindSnapshotParts(shared.refs, resolution)) : undefined, ); - if (shared.mode !== 'snapshot') { + if (!started || shared.mode !== 'snapshot') { return; } @@ -1142,7 +1354,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 +1385,7 @@ export const useSearchRun = () => { ), ), bound.map((part) => part.index), + 'shared', ); return; } @@ -1214,7 +1427,7 @@ export const useSearchRun = () => { ); }; - runDatasetEdit({ rewriteIdentity }, touched); + runDatasetEdit({ rewriteIdentity }, touched, 'shared'); }, [entityOptionsBySpec, facetOverride, parts, runDatasetEdit], ); @@ -1248,5 +1461,6 @@ export const useSearchRun = () => { editMeasure, editParsed, editShared, + searchIds, }; }; 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/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/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/client.ts b/src/lib/events/client.ts index d37c828..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); } @@ -56,8 +57,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 +73,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/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/posthog.ts b/src/lib/events/posthog.ts index dfbec21..3a1b399 100644 --- a/src/lib/events/posthog.ts +++ b/src/lib/events/posthog.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto'; + 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 +7,55 @@ import type { EventName, EventProps, Who } from './track'; export const posthogConfigured = (): boolean => Boolean(process.env.POSTHOG_KEY && process.env.POSTHOG_HOST); +// 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(); + + return code && /^[A-Z]{2}$/.test(code) && code !== 'XX' ? code : null; +}; + /** * 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. 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 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), + country: visitorCountry(who.headers), + ...(userAgent && { $raw_user_agent: userAgent }), + $geoip_disable: true, + ...(!person && { $process_person_profile: false }), }, timestamp: new Date().toISOString(), }), 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 4a85210..bfb6e7e 100644 --- a/src/lib/events/track.ts +++ b/src/lib/events/track.ts @@ -2,35 +2,47 @@ 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, * 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, 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 - * - 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) + * - share.open a share link opened at /s/ (share id, kind, mode, host site) * - gallery.open a visitor opened a Gallery item (slug, kind) */ 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', @@ -40,6 +52,7 @@ export const EVENT_NAMES = [ 'feedback.submit', 'embed.view', 'share.create', + 'share.open', 'gallery.open', ] as const; @@ -47,18 +60,28 @@ 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; }; /** * 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 = {}) => { +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/searchClient.ts b/src/lib/search/searchClient.ts index 1f24cf0..0158b38 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 @@ -1110,6 +1114,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..15d56ec 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,65 @@ 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 `/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 = + '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 +212,7 @@ export function searchEventProps({ reason, parts, seeds, + run, }: { query: string; edited: boolean; @@ -157,6 +221,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 +279,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 +317,185 @@ 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'; + +/** 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 }; + +/** 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; + /** The page the post started on; a `left` abandon runs after the URL has changed. */ + page: string; + }, + { + emit, + now, + }: { + emit: (name: SearchPostEventName, props: Record, page: string) => 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 }), + }, + post.page, + ); + }; + + 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, post.page); + }, + fail, + abandon(reason: Exclude) { + if (!end()) { + return; + } + + emit('search.abandon', { ...runProps(), reason, step }, post.page); + }, + hide() { + if (ended || hidden) { + return; + } + + emit('search.abandon', { ...runProps(), reason: 'hidden', step }, post.page); + hidden = true; + }, + }; +} + +/** 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, and the page it was on. + */ +export function startSearchAction( + searchIds: () => SearchIds, + emit: (name: 'search.action', props: Record, page: string) => void, + page: string, +): (action: TAction) => void { + const ids = searchIds(); + + 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 6d6b9db..7eac67d 100644 --- a/src/lib/search/test/searchEvents.test.ts +++ b/src/lib/search/test/searchEvents.test.ts @@ -3,8 +3,19 @@ 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 { + HANDOFF_SOURCE_PARAM, + handoffHref, + handoffSource, + searchEventProps, + searchOutcome, + searchPostTracker, + startSearchAction, + type SearchIds, + 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 +301,306 @@ 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, string][] = []; + let clock = 1000; + const tracker = searchPostTracker( + { + searchId: 'search-1', + post, + source: 'example', + editKind: post > 0 ? 'slot' : null, + query: 'ODA to Kenya', + page: '/data', + }, + { emit: (name, props, page) => sent.push([name, props, page]), 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 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), + ['search.query'], + ); + assert.deepEqual( + 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('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, string][] = []; + let onScreen: SearchIds = { searchId: 'search-a', traceId: 'trace-a' }; + const send = startSearchAction<{ action: 'share'; shareId: string }>( + () => onScreen, + (name, props, page) => sent.push([name, props, page]), + '/data', + ); + + 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' }, + '/data', + ], + ]); +}); + +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'], + ], + ); +}); + +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 new file mode 100644 index 0000000..016e265 --- /dev/null +++ b/src/lib/search/test/unmountTeardown.test.ts @@ -0,0 +1,54 @@ +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); + assert.equal(guard.left(), false); +}); + +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); + 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 new file mode 100644 index 0000000..1f58d5b --- /dev/null +++ b/src/lib/search/unmountTeardown.ts @@ -0,0 +1,48 @@ +/** + * 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. + * + * `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; left: () => boolean } { + let mounted = false; + let pending = false; + let left = false; + + return { + mount() { + mounted = true; + left = false; + }, + unmount() { + mounted = false; + + if (pending) { + return; + } + + pending = true; + defer(() => { + pending = false; + + if (!mounted) { + left = true; + teardown(); + } + }); + }, + left: () => left, + }; +} 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); +}); diff --git a/src/lib/test/events-posthog.test.ts b/src/lib/test/events-posthog.test.ts index 929a081..29f6b5c 100644 --- a/src/lib/test/events-posthog.test.ts +++ b/src/lib/test/events-posthog.test.ts @@ -44,7 +44,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,28 +58,104 @@ 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 as it arrives through Cloudflare and the load balancer. +const browserHeaders = (country = 'GB') => + new Headers({ + 'user-agent': UA, + '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', personless: false }, + { + kind: 'signed in without consent', + who: { userId: 'u-789' }, + distinctId: 'u-789', + personless: false, + }, + { kind: 'anonymous', who: {}, distinctId: UUID, 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.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 country', 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.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); await assert.rejects( - () => sendEventToPosthog('guest.wall_hit', {}, {}), + () => sendEventToPosthog('guest.wall_hit', {}, { headers: null }), /PostHog answered 500/, ); });