@shware/analytics
v16.3.6
Published
Readme
@shware/analytics
First-party product and marketing analytics for web (Next.js, TanStack Start, React Router) and
React Native (Expo). One track() call records the event against your own backend, forwards it to
the browser pixels you configure (GA4, PostHog, Meta, Reddit, LinkedIn, OpenAI, Microsoft UET),
and carries the visit's attribution (UTM, click ids, referrer) so the server can send the same
conversion on to the ad platforms' conversions APIs.
Related guides in this folder: GA4.md (how sessions and engagement line up with GA4) and UTM.md (how to tag campaign links).
Install
pnpm add @shware/analytics zodEvery framework and platform integration is an optional peer dependency: install only what your
entry point imports (next, @tanstack/react-start, the expo-*
modules for /native, react-native-fbsdk-next for /native/fbsdk, and so on).
Entry points
| Import | Runs on | What it is for |
| ----------------------------------- | ------- | --------------------------------------------------------------------------- |
| @shware/analytics | client | setupAnalytics, track, identify, reset, sendFeedback, links, bots |
| @shware/analytics/web | browser | storage, getTags, getDeviceId for setupAnalytics on the web |
| @shware/analytics/native | Expo | The same for React Native, plus deepLink and useAppAnalytics |
| @shware/analytics/native/firebase | Expo | firebaseAnalytics(), the integration for Firebase Analytics |
| @shware/analytics/native/fbsdk | Expo | metaAppEvents(), the integration for Facebook App Events |
| @shware/analytics/next | browser | <Analytics /> for the Next.js App Router |
| @shware/analytics/react | browser | <Analytics /> for any React router (TanStack Router, React Router, …) |
| @shware/analytics/tanstack | both | createClickIdMiddleware for TanStack Start |
| @shware/analytics/third-parties | browser | GA4, Meta Pixel, Reddit, LinkedIn, OpenAI and UET trackers and setters |
| @shware/analytics/server | server | Conversions APIs: Meta, Google Ads, Microsoft, Reddit, LinkedIn, OpenAI |
| @shware/analytics/attribution | server | classifyTouch and the channel vocabulary for reading sessions |
| @shware/analytics/ads | server | Pulling spend and performance from Google Ads and Meta |
Client-side only
Everything this SDK holds — the session, the visitor, the tags cache, the config itself — lives in
module singletons, because there is exactly one visitor per browser or app. The package can be
imported on a server (nothing is constructed at module scope, so it evaluates fine under Cloudflare
Workers), but calling track() there shares one session and one visitor across every request the
isolate serves.
Server-side conversions go the other way: store the client's events, then hand them to
@shware/analytics/server, which takes a TrackEvent and forwards it to the Meta, Reddit, OpenAI,
LinkedIn, Google Ads and Microsoft Advertising conversions APIs.
Setup
Call setupAnalytics once, at module scope of the client entry, before anything tracks. Web:
import { setupAnalytics } from '@shware/analytics';
import { getDeviceId, getTags, storage } from '@shware/analytics/web';
import { version } from '../package.json';
import { integrations } from './integrations';
setupAnalytics({
storage,
getTags,
getDeviceId,
release: version,
platform: 'web',
environment: process.env.NODE_ENV === 'development' ? 'development' : 'production',
endpoint: 'https://api.example.com/v1/analytics',
integrations,
// Still sent to the analytics server, but to no integration: report them server-side instead.
excludeFromIntegrations: ['purchase', 'refund'],
});Integrations
Each third party is one integrations entry that carries everything about it: how events reach it, how it learns the signed-in user (identify / reset()), and the ids of the script it needs. Pass the same array to <Analytics integrations>, which renders those scripts, so an id is written once.
// integrations.ts
import {
googleAnalytics,
linkedInAds,
metaAds,
microsoftAds,
posthogAnalytics,
} from '@shware/analytics/third-parties';
export const integrations = [
googleAnalytics({ id: 'G-XXXXXXX', debug: import.meta.env.DEV }),
posthogAnalytics({
token: 'phc_XXXXXXX',
config: { api_host: 'https://us.i.posthog.com', defaults: '2026-01-30' },
}),
metaAds({ pixelId: '1234567890', customEvents: ['onboarding_completed'] }),
linkedInAds({ partnerId: '123456', conversions: { sign_up: 7654321 } }),
microsoftAds({ tagId: '987654', customerId: '1234' }),
];| Type | Integrations | Receives |
| ----------- | ------------------------------------- | ------------------------------------------------------------------------------------------ |
| analytics | googleAnalytics, posthogAnalytics | every event |
| ads | metaAds, redditAds, openaiAds | the events it maps to its own standard events, plus customEvents |
| ads | linkedInAds | the events in conversions, each as its conversion id |
| ads | microsoftAds | events (the GA4 conversion events, CONVERSION_EVENTS, by default), plus customEvents |
An ad platform needs conversions to optimize and build audiences, not the product's day-to-day events, which are behavior analysis for GA4 and PostHog. excludeFromIntegrations applies to every integration, analytics included. PostHog loads from its HTML snippet like the pixels, so posthog-js is not bundled: the library comes from PostHog's assets host after the page loads, and config (only its JSON survives) goes to posthog.init. Until it loads, window.posthog is a stub that queues calls returning nothing (capture, identify, onFeatureFlags) and replays them, while calls returning a value (getFeatureFlag) return undefined. So: getPosthog() for queued calls, getLoadedPosthog() (undefined until loaded) for values, and in components the flag hooks of posthog-js/react, with its names and signatures, from @shware/analytics/react: useFeatureFlagVariantKey, useFeatureFlagEnabled, useFeatureFlagPayload, useFeatureFlagResult and useActiveFeatureFlags, and usePostHog for the instance (the stub until the library loads). The flag hooks render the default until flags arrive, then the variant. To show a <Suspense> fallback until then instead, wait for them with React 19.3's browser() (so the server renders the fallback) and posthogFlagsReady() (one promise per page, resolving when flags load or after a 3 s timeout, for when an ad blocker stops the library):
import { useFeatureFlagVariantKey } from '@shware/analytics/react';
import { posthogFlagsReady } from '@shware/analytics/third-parties';
import { use } from 'react';
import { browser } from 'react-dom';
function Checkout() {
use(browser());
use(posthogFlagsReady());
return useFeatureFlagVariantKey('new-checkout') === 'test' ? <NewCheckout /> : <OldCheckout />;
}With server-evaluated flags, skip the wait: pass them in config.bootstrap and wrap the app in <PosthogProvider bootstrap={bootstrap}> with the same object (it initializes nothing: the snippet does), and the hooks render the server's value on the server, through hydration and until the library loads — the variant from the first frame, with no hydration mismatch. The exposure ($feature_flag_called) is sent once the library reads the flag.
<PosthogProvider bootstrap={bootstrap}>
<Outlet />
</PosthogProvider>For error tracking, turn it on in posthogAnalytics with errorTracking: exception autocapture (window errors, unhandled rejections) and a filter for what is not yours to fix — ignoreErrors matched against each error's Type: message, denyUrls against the file of its innermost frame, as Sentry's options of the same names. config reaches posthog.init as JSON, which drops a before_send function, so the filter is written into the snippet and applies from the first event. errorTracking: false sends no exception at all, for local development:
posthogAnalytics({
token: 'phc_…',
config: { api_host: 'https://us.i.posthog.com', defaults: '2026-01-30' },
errorTracking: isDev
? false
: { ignoreErrors: ['UET is not defined'], denyUrls: ['/gsi/client'] },
});Each exception carries the session id, so its issue links to the session's replay. capturePosthogException(error, properties?) reports one yourself — from an http client's error hook, or a catch that recovers — queued on the stub like sendPosthogEvent, and never throws. <PosthogErrorBoundary fallback={…}> (with posthog-js/react's props) sends a render error with its component stack and renders fallback instead. What no boundary of yours sees — an uncaught error, one a framework's boundary caught (TanStack Router's), a hydration mismatch React recovered from — setupPosthogReactErrorHandler() reports from React 19's root options in the client entry, logging it with console.error as React would (a callback replaces that logging): hydrateRoot(document, <StartClient />, { onUncaughtError: setupPosthogReactErrorHandler(), onCaughtError: setupPosthogReactErrorHandler(), onRecoverableError: setupPosthogReactErrorHandler() }). Every one of them goes through the errorTracking filter.
Under posthogFlagsReady() with config.bootstrap the wait ends as soon as the library loads. On Next.js its snippet uses next/script's beforeInteractive, so the stub exists before hydration tracks anything; render <Analytics> in the root layout for that. <Analytics> also preconnects to PostHog's assets host (Integration.preconnect, through react-dom's preconnect), so array.js starts sooner. The PostHog and PostHogConfig types come from @posthog/types, PostHog's types-only package for the snippet, a dependency of this one: the host does not install posthog-js for them. In an app, firebaseAnalytics() (every event) and metaAppEvents({ customEvents }) (the conversions Meta maps, like metaAds) are the integrations. A platform the SDK does not cover is an object implementing Integration (type, track, and optionally setUser and script).
getHeaders is also accepted, for a backend that wants extra headers on the analytics requests.
React Native (Expo):
import { setupAnalytics } from '@shware/analytics';
import { deepLink, getDeviceId, getTags, storage } from '@shware/analytics/native';
import { Platform } from 'react-native';
setupAnalytics({
storage,
getTags,
getDeviceId,
release: '1.0.0',
platform: Platform.OS === 'ios' ? 'ios' : 'android',
environment: __DEV__ ? 'development' : 'production',
// The link that opened the app — a universal link, an app link, a custom scheme — lands the
// visit as a web page's URL does: its URL and utm go into every event's tags until
// the app goes to the background. `setupAnalytics` starts its listeners; nothing runs at import.
deepLink,
endpoint: 'https://api.example.com/v1/analytics',
});
// An entry React Native's `Linking` does not see, such as a push notification's tap:
deepLink.open(url);In the app, call useAppAnalytics(pathname) from @shware/analytics/native in the root layout
so screen changes are tracked as page views.
The <Analytics /> component (web)
Each web framework entry exports an Analytics component. Render it once in the document head:
it tracks page views, outbound clicks and web vitals, and renders the script of each of the
integrations you pass — the same array given to setupAnalytics, so each id is written once.
Every integration carries one script (Integration.script: an inline snippet that loads the
vendor's script from its host, as the vendors' official snippets do). Next.js has its own entry
(@shware/analytics/next); any other React router uses @shware/analytics/react and passes the
current location. On TanStack Start:
import { Analytics } from '@shware/analytics/react';
import { useLocation } from '@tanstack/react-router';
import { integrations } from './integrations';
const { pathname, searchStr } = useLocation();
<head>
<HeadContent />
<Analytics pathname={pathname} search={searchStr} nonce={nonce} integrations={integrations} />
</head>;Under a nonce-based Content Security Policy, pass the request's nonce: it is set on every
<script> the component renders (the gtag loader and every pixel's inline snippet). The snippets
then load each vendor's script from its own host, so those hosts still need to be allowed in
script-src unless the policy uses 'strict-dynamic'.
On TanStack Start, also register the click-id middleware so the ad platforms' first-party cookies survive Safari's 7-day cap (see Keeping the cookies past Safari's limits):
import { createClickIdMiddleware } from '@shware/analytics/tanstack';
import { createStart } from '@tanstack/react-start';
const clickIdMiddleware = createClickIdMiddleware({ domain: '.example.com' });
export const startInstance = createStart(() => ({ requestMiddleware: [clickIdMiddleware] }));Usage
import { identify, reset, track } from '@shware/analytics';
async function login() {
const user = await api.login();
// Binds this visitor to the signed-in user (see Visitors below). Call it on every page load
// once someone is signed in; the second argument is handed to the pixels for matching only.
identify(user.id, { email: user.email });
track('login', { method: 'google' });
}
async function logout() {
await api.logout();
// `logout` first: it still belongs to the user leaving.
track('logout');
reset();
}reset() is sign-out, as in PostHog: the current session ends (its unreported engagement goes out as a user_engagement with trigger: 'reset'), a new visitor starts on the device, and the third-party setters drop the user. What happens until the next sign-in is anonymous and belongs to whoever signs in next; the visitor left behind keeps the previous user's history. Call it when the session ends any other way too (expiry, deletion), and from every tab that notices, however late: it only replaces the visitor while the device's identity is still the one that page identified, so a second tab calling it does not cut the new anonymous visitor off, nor reset the next user who has signed in since. identify resets by itself when a different user signs in than the one it last identified on the device, so a sign-out the host missed does not hand one user's visitor to the next. The device id and the first visit or first open are the device's and stay.
Typed events and strict event names
An application types its own events, and adds properties to GA4's standard ones, by declaration merging into CustomEventProperties (see its doc comment). By default an event declared nowhere still compiles, with open properties, so track('purchse', …) or track('begin_checkout ', …) (a trailing space) quietly becomes a new event.
Turn on strict to make that a compile error. Merge it into AnalyticsOptions, preferably in the same file that declares CustomEventProperties, so the list of events and the rule that only they can be tracked live together:
declare module '@shware/analytics' {
interface AnalyticsOptions {
strict: true;
}
interface CustomEventProperties {
plan_changed: { direction: 'upgrade' | 'downgrade' };
account_deleted: undefined; // an event with no properties
}
}With it on:
- Every function that takes an event name —
track,trackOnce,sendBeacon,useTrackImpressionand the third-party senders — accepts only GA4's standard events (purchase,login,page_view, …) and the keys ofCustomEventProperties. Anything else is an error at the call site, so a new event has to be declared before it can be tracked. TrackPropertiesno longer falls back to the openRecordfor an undeclared event (there is none left to track); declared and standard events are typed exactly as before.
It is type-only: nothing changes at runtime, and without the merge the SDK behaves exactly as it always has.
When events reach the pixels and the server
Every event gets its id on the client, a uuidv7 generated by track() (and trackOnce()) at the moment it is tracked. The same id goes to both places:
- The pixels get it at once. The third-party trackers you configure are called synchronously inside
track(), with the id as their deduplication id (table below). They do not wait for the server, the 2 s batch window or a beacon, so an event right before a full-page navigation — an outbound link,begin_checkoutbefore the redirect to Stripe, a landing page left at once — reaches the pixels before the page goes. A server that is down does not stop them either. A tracker whose pixel has not loaded yet (the SDK's trackers returnfalsewhen their global —fbq,rdt,gtag, … — is missing) is called once more on the next macrotask, with the same event and id, once the inline snippets have run; one that is still missing then is skipped, as before. - The server gets it in the batch (
idin each event ofPOST /events), sent within 2 s, or right away by beacon when the page is hidden or left (sendPendingEvents, which the frameworkAnalyticscomponents call onpagehideandvisibilitychange). A beacon fires no tracker again.
The server's conversions API, sending the stored event under the same id, then deduplicates against the browser event, as before.
| Tracker | Deduplication parameter the id is passed as |
| ------------------------------------------ | ------------------------------------------------ |
| Meta Pixel (sendFBEvent) | eventID (Conversions API event_id) |
| Reddit Pixel (sendRedditEvent) | conversionId (Conversions API conversion_id) |
| LinkedIn Insight Tag (sendLinkedinEvent) | event_id (Conversions API eventId) |
| Microsoft UET (sendUETEvent) | event_id (Conversions API eventId) |
| OpenAI pixel (sendOpenAIEvent) | event_id |
| GA4, PostHog, Firebase, Facebook SDK | not passed: they have no browser/server pairing |
Nothing waits for the server: track fires the pixels at once and the batch reaches the server within 2 s or by beacon when the page is left, so there is no trackAsync (removed in 13.0). A step that must not run before the server has the event does not belong in the client; record it server-side.
Conversions APIs on the server (setupConversions)
The server's counterpart of setupAnalytics({ integrations }), configured on the server alone:
each ad platform's Conversions API once, sent what its browser integration is sent — the
platform's standard events and the customEvents it lists, nothing else. The options are the
browser integrations' (MetaAdsOptions, …) plus a token; give a platform the same
customEvents on both sides for the tag and the API to report the same events.
import {
linkedinConversions,
metaConversions,
redditConversions,
setupConversions,
} from '@shware/analytics/server';
export const conversions = setupConversions({
destinations: [
metaConversions({
accessToken: env.META_CONVERSIONS_API_ACCESS_TOKEN,
pixelId: '1234567890',
customEvents: ['onboarding_completed'],
}),
redditConversions({ accessToken: env.REDDIT_CONVERSIONS_API_ACCESS_TOKEN, pixelId: 'a2_xxx' }),
linkedinConversions({
accessToken: env.LINKEDIN_CONVERSIONS_API_ACCESS_TOKEN,
conversions: { sign_up: 7654321, purchase: 7654322 },
}),
// a CRM or the host's own workflows: any `{ send(events, data) }`
{ send: (events, data) => sendToCrm(events, data) },
],
// Only for conversions.track(), which is returned only with it; forward needs none of it.
track: {
resolveUser: async (userId) => latestSessionOf(userId),
store: async (event) => (await insertEvents([event])).length > 0,
},
});
// POST /events, after storing the batch
ctx.waitUntil(conversions.forward(events, userProvidedData));
// a webhook: Stripe's invoice.paid, once the account is credited
ctx.waitUntil(
conversions.track({
id: invoice.id,
name: 'purchase',
userId,
properties: { transaction_id: invoice.id, value: 9.99, currency: 'usd', items: [] },
createdAt: new Date(invoice.created * 1000).toISOString(),
})
);forward(events, data): the events a client sent, as stored, to every destination at once.track(event): an event the server reports on its own — a webhook's purchase, renewal or refund — withidthe transaction id.track.resolveUser(userId)returns what a client event would have carried, from the host's records: the user's latest session'stags(cookies, page, user agent) with the click ids of the touch the conversion is credited to, that session'svisitor_id,session_id,platform(which decides website or app) andenvironment, and the user's identity asdata.track.storestores the event — in the events table, so the server's conversions count wherever the client's do — and says whether it was new: only a new one is sent, so a webhook delivered again is not reported twice. That matters: the platforms document deduplicating a server event against a browser one, and only Microsoft (oneventId) and Google Ads (ontransaction_id) a server event sent twice. A user with no session is skipped.
Neither throws, so reporting a conversion cannot fail the request it runs in — least of all a
payment webhook, which its provider would deliver again and again. A failure is logged with the
ids of the events it lost (Conversions not sent, events: …), to be found and sent later: a
missed conversion costs less than a duplicate one or a failing webhook. The send…Events
functions underneath reject instead (ConversionsApiError for an error status, the fetch error
for a network failure; neither carries a token), for a caller using them directly to handle.
List events the server reports itself in setupAnalytics({ excludeFromIntegrations }) so the
browser does not send them too.
Tracking an occurrence once (trackOnce)
A payment result page can be reloaded or reopened from history, and a store transaction can be delivered again; each would report the same purchase again. trackOnce takes the key that identifies the occurrence and tracks it at most once per key:
import { trackOnce } from '@shware/analytics';
trackOnce('purchase', properties, { key: checkoutSessionId });keyis required and never derived from the properties; the same key under another event name is a different occurrence.ttl(ms, 90 days by default) is how long a sent key is remembered, up to the 20 latest keys per event.- The key is stored in the
storagegiven tosetupAnalyticsonly once the event has been sent, so a failed send is tried again by the next call; within one page a queued key is not queued twice. The record is plain JSON undertrack_once.<event>, e.g.{"v":2,"entries":[["cs_test_a1B2c3","019485d9-8b41-7000-8000-000000000003",1799236800000,true]]}(key, event id, expiry, sent); a storage that needs escaping (a cookie) escapes it itself. - If the storage cannot be read or written the event is tracked anyway: a rare duplicate beats a lost conversion. Deduplication is per device, so exact figures such as revenue belong to server-side records.
- The pixels fire when the event is tracked, so a send that fails has already reported the occurrence to them. Each entry keeps the event id the key goes out under and whether it was sent; a call for a key that is remembered but not sent (a failed send, a page reloaded before its batch went out) reuses the id and fires no pixel again. An event that left by beacon (
sendPendingEvents, the page hidden or left) stays unsent too, since a beacon cannot tell delivered from dropped: the next call for the key sends it once more under the same id, which the server ignores if the beacon did arrive. Version 1 records ([key, expiresAt]) are read as sent keys.
Page views
page_view fires on a path or query change, never on a hash change — GA4's enhanced measurement,
Next.js's usePathname + useSearchParams pattern and PostHog's history_change all draw the
line there (?page=2 is another page to a funnel, #faq is not). Tracking parameters (utm_*
and the ad click ids) are ignored when comparing, so a landing page that cleans them out of its
URL with replaceState does not count itself twice. page_load_id rotates on exactly the same
changes. The framework Analytics components wire this up.
Mount one analytics reporter per navigation stream. previous_page_path and
previous_screen_class identify the preceding view queued by that reporter: for
A → B → C → back to B, the predecessors are undefined, A, B, and C. History advances
when the view is queued, without waiting for delivery, and survives Activity effect cleanup.
Restoring B emits another view with B as its predecessor and settles engagement time, even
when React Compiler reuses the page element without rendering it again. Development Strict
Mode effect replay also queues another view, so its predecessor is the first view itself.
page_referrer describes the navigation source instead: the document's initial
document.referrer, then the preceding page URL after an in-app navigation. Restoring the same
page preserves that navigation referrer and page_load_id. Unrelated renders and uncommitted
navigation do not advance the reporter's view history.
page_location and page_referrer (and a native deep link's page_location) are reported
without their credential-bearing query parameters, stripped on the client before anything is
sent: code, state, token, access_token, id_token, refresh_token, session_id,
api_key, apikey, secret, password and otp, matched case-insensitively, in the query and
in a hash shaped like one (an implicit flow's #access_token=…). Every other parameter is kept.
Backend API
- /analytics/tracks: track events
- /analytics/visitor: app visitors
Events
POST /events takes a batch read by createTrackEventSchema. Since 12.1 every event carries id, the uuidv7 the client generated when it was tracked and gave the pixels (see "When events reach the pixels and the server"); the schema checks that it, visitor_id and session_id are version 7 uuids — all three are generated by the SDK, so the check lives here and the server need not repeat it. id is optional, for clients before 12.1. The server:
stores each event under its
id, generating one only when it is absent (an older client), and inserts withon conflict (id) do nothing. The batch request is retried byfetchon a network error, a 408, 429 or 5xx; a server that committed the batch and lost its answer receives it again, and the conflict clause makes that retry store nothing twice. Beacons are not retried, but a beacon and a send of the same queue cannot both happen. With Drizzle:const rows = events.map((e) => ({ id: e.id ?? uuidv7(), name: e.name /* , … */ })); await tx.insert(event).values(rows).onConflictDoNothing({ target: event.id });A retried batch also forwards nothing twice when what you forward to (a journey engine, a conversions API) is keyed by the same id.
sends the stored id to the conversions APIs as the event id, so they pair with the pixels the client fired under it.
answers one
{ id }per event, in order (TrackEventResponse), the stored ids. 12.1 clients no longer read the answer — any 2xx is a stored batch — but clients before 12.1 fire their pixels with the ids found there, so keep it while they are in use.
Visitors
Since 11.0 the SDK generates the visitor id itself — a uuidv7, kept in config.storage under
visitor_id (visitorId()) — as GA4 does its client id and PostHog its anonymous distinct id. The
first events of a visit, and the beacon of a visit left within a second, go out without waiting
for a round trip; before, a new visitor's events waited for POST /visitors, and a page left
before it returned lost them all, session_start included. An id a server issued to an older
client is kept as it is, so the visitor continues.
A visit sends no visitor request: as in PostHog, where person properties ride on the events, the events are the visitor's record. The server:
- creates the visitor from
POST /events: read the batch's visitors once — the query that also tells which of them are bots — and create the missing ones before inserting the events, withinsert ... on conflict (id) do nothing. The conflict clause only settles two batches of the same new visitor arriving at once: the second waits on the primary key until the first commits and then does nothing, so the events' foreign key to the visitor holds. Build the row only for the missing ids, from that visitor's first event in the batch:device_idfrom its tags (else the visitor id),platformandenvironmentfrom the event, its tags (with the request's geolocation and user agent) as bothtagsandinitial_tags, and the bot verdict from them. - refreshes
visitor.tagsfrom eachsession_start:tagsis the last-touch counterpart toinitial_tags(browser, release, landing), and a session's opening is where a visit came in, so once per session is both enough and the right moment.identifysends no tags (11.1): at sign-in the page is the login page, and its tags would overwrite the landing's. Clients before 11.1 still send tags on their PATCH; merge them when present. - binds it to the user from an
identifyevent (IDENTIFY_EVENT):identify(11.2) sends no request of its own — it hands the user to gtag and the pixels at once, and queuesidentifywith{ user_id }when the user differs from the one the page last identified. In the batch's transaction, after creating the visitor, set itsuser_idanddistinct_idto that user (taking only a valid uuid, so a host's own event of that name binds nothing). Creation and binding then travel in one request, in order: the binding can never arrive before the visitor.identifyis the SDK's: never forward it to the ad platforms or a CRM. - binds a visitor to one user only (15): bind only while the visitor has no user or already has this one; a visitor bound to someone else keeps them, and the identify binds nothing. From 15 the SDK starts a new visitor at sign-out and before another user's sign-in, so its own identify never meets this — but a device that upgrades from a client before 15 while signed out has no user in its storage to compare, and without this the next account signing in there would take the previous user's whole history. That account's events stay on the old visitor until it signs out. Ship this together with the upgrade to 15, not before: a client before 15 never starts a new visitor, so the first user on a shared device would keep every later user's events.
- keeps
PATCH /visitors/:idfor clients before 11.2, which bind the user there — answer 404 for a visitor no event has created, and merge thetagsof clients before 11.1 when present. The same one-user rule applies to its binding. - keeps
POST /visitors(createVisitorSchema) while clients before 11.0 are in use.
Deploy the server first: a server that only creates visitors on POST /visitors rejects the
events of a new visitor from an 11.0 client (the foreign key fails).
Feedback
sendFeedback(dto) POSTs { name, email, message, turnstileToken } to <endpoint>/feedback;
createFeedbackSchema reads it on the server, trimming and lower-casing email. The endpoint is an
unauthenticated free-text write, so a Cloudflare Turnstile token is required, as it is on
@shware/security's sendEmailVerificationCode: render the widget in the form, pass its token as
turnstileToken, and verify it server-side against Cloudflare's siteverify before storing the
row, answering a rejected token as @shware/security does (400 INVALID_ARGUMENT, reason
TURNSTILE_VERIFICATION_FAILED). The token is single-use — Turnstile spends it at verification —
so reset the widget after every submit, whether it succeeded or failed. The request is not
retried: it has no deduplication key, and a retry would carry a spent token.
Sessions and attribution
How the events this SDK sends are meant to be read on the server side, for attribution. Three layers, each derived from the one below it:
- Session — one row per
session_start. The SDK opens a session after 30 minutes without an event and sends exactly onesession_startat the head of the batch that opened it, carrying the tags of the event that opened it: the landing page's URL, utm parameters and click ids, captured when that event happened rather than when the batch went out. A partial unique index onevent (session_id) where name = 'session_start'makes that row the session's one record. The server reads its tags once, when it writes the session, withclassifyTouch(@shware/analytics/attribution) and stores the touch on the session row; a rule change is applied to history by reclassifying. - Touchpoint — one row per touch: every session with its stored touch, plus the touches people report (a survey answer, a call on a channel's number). Nothing is derived at query time.
- Attribution — one row per session, credited to the strongest touch of the same person (across devices) within a window, the session's own touch included. See the rules below.
How a session is read as a touch (classifyTouch)
The channel and medium come from the first rule that says something:
| Order | Signal | channel | medium |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| 1 | utm_source in the URL | the source, aliases folded (fb, ig → meta) | utm_medium, or (not set) |
| 2 | A click id in the URL (gclid, fbclid, rdt_cid, …; never the cookie an earlier click left) | the id's platform | cpc |
| 3 | An ad landing page, /lp/<channel> | that channel | cpc |
| 4 | The product's own rules (TouchRule, e.g. a referral link) | as the rule says | as the rule says |
| 5 | The referrer's host; an Android app's android-app://<package> read as the site it stands for (ANDROID_APP_HOSTS: the Google app is a Google search) | a known search engine, social network, video site, AI assistant or webmail folded to its channel; any other host (or unlisted app package) kept as is | organic / social / video / ai / email / referral |
| 6 | Nothing | (direct) | (none) |
One exception: an ad-only click id of the channel itself ('ads' in CLICK_ID_CHANNELS), or the
channel's ad landing page, proves the click was paid, so the medium becomes cpc when it was left
out or puts the touch in an organic group — a Reddit ad tagged utm_source=reddit&utm_medium=social
that carries rdt_cid. A declared paid medium is kept as it is. fbclid never proves anything:
Meta puts it on organic links too; nor does another channel's id (an ad link copied and shared
under a utm of its own).
channel_group is GA4's default channel grouping of the two (channelGroupOf): paid_search,
organic_search, paid_social, email, ….
The touch's priority (TOUCH_PRIORITY, lower is stronger) decides which touch wins in the
attribution layer:
| Priority | Touches |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 — campaign | A utm, click id or ad landing page whose group is paid, display, affiliate or unassigned; a product rule that says so (a referral link); a reported touch staff stand behind (phone, manual) |
| 2 — referrer | A touch read from the referrer (organic search, social, other sites); and a utm, click id or ad landing page whose group comes out organic_*, referral or email (utm_source=chatgpt.com on ChatGPT's organic citations, utm_medium=organic, a newsletter) |
| 3 — claimed | A reported touch that is the user's claim (survey, promo_code) |
| none | Direct: no touch |
When a product rule matched as well as a campaign rule, the stronger tier holds: a referral link
shared under utm_source=facebook is named by the utm (meta, organic social) and still ranks as
the referral programme declares.
In short: a link someone paid for is 1; what the browser brought, a tag that calls itself organic, and our own emails are 2; what the user says is 3. Email is 2 because it reaches people we already know, often because an ad brought them, and its links (a welcome mail, a trial reminder) come between that ad and the purchase: it must not take the ad's credit, and with no ad in the window it still wins as the latest touch.
How a session is credited (the attribution view)
For every session, among the same person's touches within the window (30 days in the reference views), the session's own touch included:
- the lowest
prioritywins; - among equals, the latest wins;
- on a full tie, the session's own touch wins.
| touch_kind | Meaning |
| ------------ | -------------------------------------------------------------------- |
| own | The session's own touch won |
| inherited | A direct session, credited to an earlier touch |
| overridden | The session had a touch of its own, but an earlier, stronger one won |
| none | A direct session with no touch in the window |
| # | What happened | The current session is credited to | touch_kind | Why |
| --- | ---------------------------------------------------------------------------------- | ---------------------------------- | -------------------- | ----------------------------------- |
| 1 | Day 1 a Meta ad click, day 2 a branded Google search | Meta | overridden | 1 beats 2 |
| 2 | Day 1 a Meta ad click, day 2 typed in | Meta | inherited | direct inherits |
| 3 | Day 1 an organic search, day 2 a Meta ad click | Meta | own | its own touch is stronger |
| 4 | Day 1 a Meta ad click, day 3 a Google ad click | Google | own | both 1, the latest wins |
| 5 | Day 1 a Meta ad click, day 2 a ChatGPT citation with its utm | Meta | overridden | ChatGPT's organic utm ranks 2 |
| 6 | Day 1 an organic search, day 5 a ChatGPT citation | ChatGPT | own | both 2, the latest wins |
| 7 | An ad click 45 days ago, an organic search today | Organic search | own | the ad is out of the window |
| 8 | A Reddit ad tagged utm_medium=social with rdt_cid | reddit / cpc | own | the click id proves a paid click |
| 9 | Day 1 a Meta ad click, day 2 a newsletter link (utm_medium=email) | Meta | overridden | email ranks 2 |
| 10 | Day 1 an organic search, day 2 a newsletter link, no ad in the window | Email | own | both 2, the latest wins |
| 11 | A survey answer "a podcast" (backdated to the first visit), then an organic search | Organic search | own | 2 beats 3; a claim only fills a gap |
| 12 | Day 1 a ChatGPT citation, day 2 a branded Google search | Organic search | own | both 2, the latest wins |
| 13 | Day 1 a ChatGPT citation, day 2 a ChatGPT ad click (oppref) | chatgpt / cpc | own | the ad is 1 |
| 14 | Day 1 a Meta ad click, day 2 a ChatGPT citation, day 3 typed in | Meta | inherited | 1 beats the citation's 2 |
| 15 | The ChatGPT app opens a link with neither referrer nor utm | Direct, or what came before | none / inherited | nothing to read |
How this differs from GA4. GA4's last non-direct click credits the latest touch that is not
direct, whatever it was: cases 1, 5 and 9 go to the search, ChatGPT and the email. Here an ad click is not
overwritten by the organic visit that follows it within the window, which is what a paid ROAS
panel expects. The touchpoint layer still has every session's own touch, so the GA4-style figure
is one query away, and overridden measures the difference.
Retargeting. No click-based model can tell whether an ad caused the visit, and retargeting is where that shows: someone who would have come back anyway clicks a retargeting ad on the way in. For a returning customer who was going to buy anyway:
| What happened | GA4 last non-direct click | This model | | ----------------------------------------------------------------- | ------------------------- | ---------- | | Clicks a retargeting ad, buys in that session | the ad | the ad | | Clicks a retargeting ad, types the site in the next day and buys | the ad (direct inherits) | the ad | | Clicks a retargeting ad, searches the brand the next day and buys | the search | the ad |
GA4's model only removes the third row, and that row is the credit a prospecting ad deserves (the ad made the brand known, the search followed); the first two rows, where most of retargeting's inflation is, stay in both. So crediting the search is not a fix. Read retargeting through the report instead of the model:
- Acquisition by first touch. A person's first touch (
user_attributionin the reference views) is settled long before any retargeting reaches them, so new users and their lifetime value by first channel leave retargeting out by construction. Judge acquisition ROAS there. - Keep retargeting apart. Name retargeting campaigns so they can be told apart
(
utm_campaignwith anrt_prefix, say), and read the session-level attribution of those campaigns split into new and returning people: a high share of returning people is credit that was probably not incremental. - Measure the increment when the spend justifies it. A holdout (Meta's Conversion Lift, a geo split) is the only way to answer whether the ad caused the purchase.
Special cases in the model ("an ad clicked by a paying customer does not override") are not worth it: the rules get harder to explain, and the first two rows remain.
AI assistants (GEO)
Visits an AI assistant sends — a citation in ChatGPT, Perplexity, Gemini, Claude, Copilot,
DeepSeek and the others in REFERRER_SITES — are organic_ai, read from the referrer or from the
assistant's own utm (utm_source=chatgpt.com). They rank with organic search, priority 2, on
purpose: both are earned, not bought, and come from a question the user asked.
- Not above SEO: a citation would otherwise take the credit from the ad clicked before it, and from the search that followed it.
- Not below SEO: "ask ChatGPT, then search the brand" would then always go to the search, and GEO would read lower than it is.
- Apart from ChatGPT's ads: a click on a ChatGPT ad carries
opprefand is paid, priority 1 (paid_other); the citations next to it stayorganic_ai.
GA4's default grouping has no AI group and files these visits under Referral; organic_ai is an
addition so GEO and SEO can be compared side by side.
What the data cannot show, and how to read around it:
- Dark AI traffic. The assistants' desktop and mobile apps, and links copied into a browser, often arrive with neither referrer nor utm, and are counted as direct (case 15). GEO is always undercounted.
- Discovery credited to search. Someone who learns the brand from an assistant and searches it the next day is credited to the search (case 12), as in every last-touch model.
- Read GEO by first touch (
user_attributionin the reference views), where the assistant that introduced the brand keeps the credit, and ask: a sign-up survey ("where did you hear about us?" with the assistants as options) stored as a reported touch (survey, priority 3) never overrides a tracked touch, but measures how much of the direct traffic the assistants brought.
Compared with other tools
How other tools credit a visit, as far as their documented defaults go (they change often; check the vendor's documentation before relying on a detail):
| Tool | Main model | Direct visits | Priority between channels | Window | Across devices |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------- | --------------------------------- |
| GA4 | Data-driven by default; paid and organic last click, and Google paid channels last click, as options | Inherit the last non-direct source | Only in "Google paid channels last click", for Google Ads | 30 days (configurable) | Signed-in user id, Google signals |
| PostHog | A session's entry source ($entry_utm_*, $entry_referring_domain, grouped GA4-style) and a person's first touch ($initial_*); funnels break down by first or last touch | A direct session stays direct: no look-back | None | — | Merged persons via identify |
| Mixpanel | Picked per report: first touch, last touch, linear, U-shaped, time decay, participation | Only events with utm count as touches, so direct is skipped | None | Configurable look-back | Identity merge |
| Amplitude | Initial / latest utm user properties (latest changes only on a new utm), plus multi-touch models | Skipped | None | Configurable | Identity merge |
| Adobe Analytics | Marketing channels classified by ordered processing rules; first and last touch channel with expiry; Attribution IQ models incl. algorithmic | Usually set not to override | Yes: per channel, whether it overrides the last touch | Visit or days, configurable | Identity stitching |
| Ad platforms (Meta, Google Ads, …) | Each claims the conversions it touched (Meta: 7-day click, 1-day view by default; Google Ads: data-driven) | Not their concern | Only themselves, so platforms claim the same conversion twice | Their own | Their own accounts |
| Mobile measurement partners (AppsFlyer, Adjust) | Last click on install, with a priority hierarchy (click over view, device id match over probabilistic); organic when nothing in the window | Organic install | Yes, the core of the model | Click about 7 days, configurable; re-attribution windows | Device |
| HubSpot | A contact's original source (first touch) and multi-touch revenue models | Does not change the first touch | None | — | Contact |
Where this model stands:
| Trait | Shared with | Unlike | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | Direct visits inherit an earlier source | GA4, Mixpanel, Amplitude, Adobe — the common ground | PostHog's session-level source, which does not look back | | Channels are ranked, a paid touch over an organic one | Adobe's override rules, the MMPs' priority hierarchy — the tools built for marketing and media-buying decisions | The product analytics tools (PostHog, Mixpanel, Amplitude), where every touch is equal | | One touch per session | Every first- or last-touch model | Multi-touch models, which split the credit | | A first-touch view per person | GA4's first user source, PostHog's and Amplitude's initial properties, HubSpot's original source | — |
So this is a rule-based last-touch model with channel priority: more favourable to ads than the product analytics tools, in the same family as Adobe's and the MMPs' rules. Two consequences to expect: a platform's own dashboard will report more conversions than this model credits it with (it counts view-through, and every platform claims a shared conversion), and the sum of the platforms' own figures exceeds the real total.
Other models
The touchpoint layer keeps every touch, so other models can be added next to this one as views, without changing it:
- Multi-touch (rule-based): the credit of one conversion is split across the touches on its path, by a fixed rule — linear (equal shares), time decay (more to the recent ones), position based (40% first, 40% last, 20% spread over the middle). Useful to see which channels open a journey and which close it; the shares are a convention, not a measurement.
- Data-driven (algorithmic): a model learns from converting and non-converting paths how much each touch raises the chance of converting (Shapley values or Markov chains), and splits the credit by that. GA4's and Google Ads' default; it needs a large volume of conversions to be stable, and it is still a model of correlation, not of causation.
- Incrementality: only a holdout experiment (a conversion lift study, a geo split) measures what an ad caused. It answers a different question from every model above, and is the one to run when the spend on a channel is large enough to matter.
What the SDK guarantees for this to hold:
session_startis sent once per session, first in its batch, with the opening event's tags and timestamp. If the server rejected that batch, it goes out again with the session's next batch under the same session id; the server's unique index drops a duplicate should both land.- On native, the install referrer's
utm_*are attached only on the launch that first resolves the referrer, so a later session is a touch only when it actually arrived through something. session_idis a client-generated uuidv7 persisted inconfig.storage, shared across tabs and reloads. It is a grouping key, not an identity: event and visitor ids are the server's.- The person a visitor belongs to is the server's too:
visitor.distinct_id, the visitor's own id until someone signs in on it and the user's id from then on. The client never sends it; PostHog is identified by the user's id, which is that value once a user is known. A visitor only ever belongs to one user:reset()starts a new one at sign-out, andidentifydoes when another user signs in over the last one.
Ad performance (@shware/analytics/ads)
Spend, impressions, clicks and the platforms' own conversions, read from the ad platforms'
reporting APIs as typed AdPerformanceRows — one per ad (or the finest level a platform reports
hours at) and hour — for a host to store next to its
sessions and compute ROAS, cost per session and the click → session landing rate.
import { fetchMetaAdPerformance } from '@shware/analytics/ads';
const rows = await fetchMetaAdPerformance({
accessToken: env.META_ADS_ACCESS_TOKEN, // a system user token with ads_read
accountId: '1234567890', // with or without act_
since: '2026-10-01', // days in the ad account's time zone
until: '2026-10-07',
});- Hourly rows only.
hour_startis a UTC instant, so a sum over any span in any time zone is a sum of whole rows and lines up with the sessions grouped the same way, whatever time zone each ad account reports in. A table never mixes a day row with the hours inside it: an upsert would overwrite one with the other on their shared start, and the rest would count twice. A platform that reports only days spreads each day evenly over its hours. - The attribution vocabulary.
channel,mediumandchannel_groupare the valuesclassifyTouchgives the platform's ad clicks (meta / cpc / paid_social), so rows join sessions and attribution with no mapping. Join onchannelandchannel_grouptogether: the same channel also brings organic traffic (Google search, ChatGPT answers). - Clicks are link clicks to the site (Meta's
inline_link_clicks), the number to compare with sessions; Meta'sclicksalso counts likes and expands. - Two conversion counts.
conversions/conversion_valueare the platform's under each ad's attribution setting, view-through included — what its own dashboard shows.click_conversions/click_conversion_valueare the click-through part (Meta's 7-day click), the one to set against a click-based attribution. - Re-fetch and upsert. Platforms restate the past: a purchase is credited to the hour of the
click up to 7 days later, and spend is corrected for invalid traffic. Re-fetch today and
yesterday hourly, the last 8 days daily (the 7-day window plus a day of time zone slack) and the
last 30 weekly, upserting on (
platform,account_id,ad_id,hour_start). - Retention. Meta keeps the hourly breakdown for 13 months;
sinceearlier than that throws rather than returning the empty answer Meta gives. Long ranges are fetched in 7-day windows, as a single request over months times out on Meta's side. - Older history.
fetchMetaAdPerformanceHistoryreads the days before the hourly 13 months (Meta keeps 37) and spreads each day evenly over its hours, so the table stays hourly: whole-day sums are Meta's exactly, the hours inside such a day are an even split. Run it once, up to the day before the first hourly day stored — a day must never hold both spread and real hourly rows, as an hour without delivery is absent from the hourly data and its spread share would remain.
Google Ads, pushed by a script
Google's reporting API needs a developer token, which needs a manager account. A
Google Ads script needs neither: it runs inside
the ad account (Tools → Bulk actions → Scripts) as the account's own user, reads the report and
posts it to the host, which turns the body into rows with parseGoogleAdsScriptReport and upserts
them.
import { GoogleAdsReportError, parseGoogleAdsScriptReport } from '@shware/analytics/ads';
// POST, behind a bearer key only the script knows
try {
await upsert(parseGoogleAdsScriptReport(await request.json()));
} catch (error) {
if (error instanceof GoogleAdsReportError) return new Response(error.message, { status: 400 });
throw error;
}- Ad groups, not ads. Google reports hours down to the ad group (
segments.houris not available per ad), and a Performance Max campaign has no ad group:ad_idis the ad group id, or the campaign id for Performance Max. The script reads both, and logs a warning when they do not add up to the account's spend. - Conversions are Google's primary conversion actions, click-based (view-through ones are counted apart and have no value), so they fill the total and the click-through columns alike.
- Restatement. A conversion is credited to its click's hour up to the conversion window later (30 days by default). The script re-reads the last 3 days every hour and the last 30 once a day.
- History. Set
SINCEto the first day with spend, run the script once by hand, then clear it; it posts 7 days per request. Hourly data reaches back at least months — compare each day's sum of hours with the day in the Google Ads UI before relying on older days.
Setting it up:
- Tools → Bulk actions → Scripts → +, paste the script, set
ENDPOINTandKEY. - Authorize it before the first run: posting to a URL needs the "connect to an external service" scope, and a run without it ends at once with "Failed due to authentication errors".
- Backfill (
SINCE, above), then clearSINCEand save. - Back in the scripts list, set the Frequency column to Hourly — the editor has no schedule. The results column reads "no changes" on every run: it counts changes to the account, and the script makes none.
Google Ads emails the account owner when a run throws, as on a non-2xx answer.
const ENDPOINT = 'https://example.com/v1/adPerformance/google';
const KEY = '<my_bearer_token>'; // the bearer key the endpoint checks
const DAYS = 3; // re-read every hour
const RESTATE_DAYS = 30; // re-read once a day, at RESTATE_HOUR in the account's time zone
const RESTATE_HOUR = 5;
const CHUNK_DAYS = 7; // days per request
const SINCE = ''; // a backfill's first day, 'YYYY-MM-DD'; empty for the schedule
function main() {
const account = AdsApp.currentAccount();
const timeZone = account.getTimeZone();
const now = new Date();
const today = Utilities.formatDate(now, timeZone, 'yyyy-MM-dd');
const hour = Number(Utilities.formatDate(now, timeZone, 'H'));
const since = SINCE || addDays(today, 1 - (hour === RESTATE_HOUR ? RESTATE_DAYS : DAYS));
for (let from = since; from <= today; from = addDays(from, CHUNK_DAYS)) {
const last = addDays(from, CHUNK_DAYS - 1);
const until = last < today ? last : today;
const range = `segments.date BETWEEN '${from}' AND '${until}'`;
const metrics = `segments.date, segments.hour, metrics.cost_micros, metrics.impressions,
metrics.clicks, metrics.conversions, metrics.conversions_value`;
const rows = [
...read(`SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ${metrics}
FROM ad_group WHERE ${range} AND metrics.impressions > 0`),
...read(`SELECT campaign.id, campaign.name, ${metrics} FROM campaign
WHERE ${range} AND metrics.impressions > 0
AND campaign.advertising_channel_type = 'PERFORMANCE_MAX'`),
];
chec