npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 zod

Every 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, useTrackImpression and the third-party senders — accepts only GA4's standard events (purchase, login, page_view, …) and the keys of CustomEventProperties. Anything else is an error at the call site, so a new event has to be declared before it can be tracked.
  • TrackProperties no longer falls back to the open Record for 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_checkout before 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 return false when 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 (id in each event of POST /events), sent within 2 s, or right away by beacon when the page is hidden or left (sendPendingEvents, which the framework Analytics components call on pagehide and visibilitychange). 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 — with id the transaction id. track.resolveUser(userId) returns what a client event would have carried, from the host's records: the user's latest session's tags (cookies, page, user agent) with the click ids of the touch the conversion is credited to, that session's visitor_id, session_id, platform (which decides website or app) and environment, and the user's identity as data. track.store stores 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 (on eventId) and Google Ads (on transaction_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 });
  • key is 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 storage given to setupAnalytics only 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 under track_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 with on conflict (id) do nothing. The batch request is retried by fetch on 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, with insert ... 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_id from its tags (else the visitor id), platform and environment from the event, its tags (with the request's geolocation and user agent) as both tags and initial_tags, and the bot verdict from them.
  • refreshes visitor.tags from each session_start: tags is the last-touch counterpart to initial_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. identify sends 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 identify event (IDENTIFY_EVENT): identify (11.2) sends no request of its own — it hands the user to gtag and the pixels at once, and queues identify with { user_id } when the user differs from the one the page last identified. In the batch's transaction, after creating the visitor, set its user_id and distinct_id to 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. identify is 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/:id for clients before 11.2, which bind the user there — answer 404 for a visitor no event has created, and merge the tags of 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 one session_start at 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 on event (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, with classifyTouch (@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:

  1. the lowest priority wins;
  2. among equals, the latest wins;
  3. 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:

  1. Acquisition by first touch. A person's first touch (user_attribution in 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.
  2. Keep retargeting apart. Name retargeting campaigns so they can be told apart (utm_campaign with an rt_ 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.
  3. 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 oppref and is paid, priority 1 (paid_other); the citations next to it stay organic_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:

  1. 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.
  2. 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.
  3. Read GEO by first touch (user_attribution in 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_start is 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_id is a client-generated uuidv7 persisted in config.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, and identify does 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_start is 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, medium and channel_group are the values classifyTouch gives the platform's ad clicks (meta / cpc / paid_social), so rows join sessions and attribution with no mapping. Join on channel and channel_group together: 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's clicks also counts likes and expands.
  • Two conversion counts. conversions / conversion_value are the platform's under each ad's attribution setting, view-through included — what its own dashboard shows. click_conversions / click_conversion_value are 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; since earlier 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. fetchMetaAdPerformanceHistory reads 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.hour is not available per ad), and a Performance Max campaign has no ad group: ad_id is 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 SINCE to 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:

  1. Tools → Bulk actions → Scripts → +, paste the script, set ENDPOINT and KEY.
  2. 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".
  3. Backfill (SINCE, above), then clear SINCE and save.
  4. 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