Guide

analytics

Sooner or later someone asks how many people created a project this week, or which page they left from. @kitr/analytics gives you four calls (page, track, identify and reset) and sends each one to the services you configure: Google Analytics, Plausible, PostHog, or several at once. Page views are counted for you on every route change, every vendor call is wrapped so a blocked script never breaks a button, and calls can wait in a queue until the visitor agrees to tracking.

The examples on this page follow one scenario: Acme's app at app.acme.com counts how often Ada creates a project, which plan Grace's team is on, and when Linus pays an invoice.

@kitr/analytics runs in the browser. On the server it binds the same service so components render, but no vendor script loads. To record events your server owns (a paid invoice, a cancelled subscription), write them to your database or send them from a job; to report errors rather than usage, use monitoring.

Install

kitr add analytics

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

What kitr add does:

  • Installs @kitr/analytics.
  • Adds () => import('@kitr/analytics/provider') to providers and '@kitr/analytics' to packages in kitrrc.ts.
  • Adds VITE_GA_MEASUREMENT_ID: e.string({ optional: true }) to the public block of app/env.ts, and VITE_GA_MEASUREMENT_ID= to .env.example (and to .env when that file exists).
  • Writes config/analytics.ts (shown in Configure) and regenerates config/index.ts.
  • Writes app/analytics.ts, the typed list of your events, with sign-in.completed and project.created to start.
  • Adds a ## kitr: analytics section to AGENTS.md telling coding agents to track through useAnalytics<AppEvents>(), to declare new events in app/analytics.ts first, and never to call gtag or posthog directly.

kitr add analytics asks no questions. To edit package.json without running the package manager, pass the global flag:

kitr add analytics --no-install

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

The saas and site kits include analytics. It is a client package, so in a server-only project kitr add analytics prints a warning and skips it.

kitr remove analytics removes the provider, the packages entry, the dependency and the AGENTS.md section, and deletes config/analytics.ts. It leaves app/analytics.ts, the env variable and every track() call in your components, which you delete by hand.

Configure

kitr add analytics writes this file:

// config/analytics.ts
import { defineConfig, googleAnalytics } from '@kitr/analytics'
import { env } from '#app/env'

/**
 * With no adapters, development logs every event to the console and production sends nothing,
 * so the app works before any vendor is chosen.
 */
export default defineConfig({
  adapters: [
    ...(env.VITE_GA_MEASUREMENT_ID
      ? [googleAnalytics({ measurementId: env.VITE_GA_MEASUREMENT_ID })]
      : []),
    // plausible({ domain: 'example.com' }),
    // posthog(posthogJs, { apiKey: env.VITE_POSTHOG_KEY }),   // pnpm add posthog-js
  ],
  // Consent banners: start queued, then call analytics.enable() once the user agrees.
  // enabled: false,
  globalProps: { app: env.VITE_APP_NAME },
})

With VITE_GA_MEASUREMENT_ID empty the adapters list is empty, and the provider picks a default: in development it logs every call to the console, in production it sends nothing. Your buttons work before you have chosen a vendor.

Option Default Description
adapters [] Where calls go. Each entry is an adapter such as googleAnalytics() or plausible(). When empty, the provider uses consoleAdapter() in development and nothing in production.
enabled true Start sending at once. false queues every call (up to 100) until analytics.enable(). With the consent provider installed, analytics always starts disabled and consent enables it.
pageViews true Send page() on every route change. false leaves page views to you.
globalProps none Props merged into every page() and track() call, such as the app name or build id. Props on the call win.
debug development, when adapters is set and devtools is not installed Print every call with console.info('[analytics]', ...) and print adapter errors with console.warn. Off when the provider falls back to the console adapter, which already prints each call.
events none A type-only field that carries your event map. You never set it at run time.
Variable Used for
VITE_GA_MEASUREMENT_ID Your Google Analytics 4 measurement id, such as G-ACME12345. When set, the generated config adds the Google Analytics adapter.
VITE_APP_NAME Already in your env. The generated config sends it as the app prop on every event.

Variables that start with VITE_ are bundled into the browser code. Measurement ids and public project keys are meant to be public; never put a secret API key in one.

Track your first event

kitr add wrote app/analytics.ts. It lists every event the app sends, with the props each one carries:

// app/analytics.ts
import type { EventMap } from '@kitr/analytics'

/** Every event the app sends, with its props. `useAnalytics<AppEvents>()` checks names and props. */
export interface AppEvents extends EventMap {
  'sign-in.completed': { method: 'password' | 'oauth' }
  'project.created': { id: string }
}

In a component, get the analytics service with useAnalytics() from @kitr/analytics/react and call track() after the thing happened:

// app/components/projects/new-project-button.tsx
import { useAnalytics } from '@kitr/analytics/react'
import type { AppEvents } from '#app/analytics'

export function NewProjectButton({ onCreate }: { onCreate: () => Promise<{ id: string }> }) {
  const analytics = useAnalytics<AppEvents>()
  return (
    <button
      type="button"
      onClick={async () => {
        const project = await onCreate()
        analytics.track('project.created', { id: project.id })
      }}
    >
      New project
    </button>
  )
}

Render it on the projects page and click it. In development, with no adapter configured, the browser console shows:

[analytics] track project.created { app: 'acme', id: 'prj_42' }

The app prop comes from globalProps in config/analytics.ts. The line prints once, from the console adapter the provider uses when adapters is empty. With devtools installed, the call also appears in the Analytics panel.

Set VITE_GA_MEASUREMENT_ID=G-ACME12345 in .env, restart the dev server, and the same click sends a project.created event to Google Analytics. No component changes.

Declare your events

Add a line to AppEvents for every new event. Use undefined for an event without props:

// app/analytics.ts
import type { EventMap } from '@kitr/analytics'

export interface AppEvents extends EventMap {
  'sign-in.completed': { method: 'password' | 'oauth' }
  'project.created': { id: string }
  'invoice.paid': { invoiceId: string; amount: number; currency: 'USD' | 'EUR' }
  'team.invited': { teamId: string; role: 'admin' | 'member' }
  'upgrade.clicked': undefined
}

useAnalytics<AppEvents>() gives your editor the list of names and the props for each, so analytics.track('inv completes to 'invoice.paid' and shows its props:

// app/components/invoices/pay-button.tsx
import { useAnalytics } from '@kitr/analytics/react'
import type { AppEvents } from '#app/analytics'

export function PayButton({ invoiceId, amount }: { invoiceId: string; amount: number }) {
  const analytics = useAnalytics<AppEvents>()
  return (
    <button
      type="button"
      onClick={() => analytics.track('invoice.paid', { invoiceId, amount, currency: 'USD' })}
    >
      Pay {amount} USD
    </button>
  )
}

With an event map, track() accepts only its names, each with its own props: a misspelt 'invoce.paid', a missing prop or a prop of the wrong type is a type error. Without one (useAnalytics() with no type argument), it takes any name and any props.

Name events object.action in the past tense (project.created, invoice.paid), the way the generated file does. Most analytics services group events by name, and one naming rule keeps their dashboards readable.

defineEvents<AppEvents>() ties the event map to a config object. It returns its input unchanged and exists for code that builds its own analytics instance:

// app/lib/onboarding/analytics.ts
import { consoleAdapter, createAnalytics, defineEvents } from '@kitr/analytics'
import type { AppEvents } from '#app/analytics'

const config = defineEvents<AppEvents>()({ adapters: [consoleAdapter({ prefix: '[onboarding]' })] })

export const onboardingAnalytics = createAnalytics<AppEvents>(config)

Track page views

The provider sends a page view for you. It renders a small tracker after the app mounts that reads the router from the container and calls page():

  • once for the page the visitor lands on, and
  • again after every navigation that changes the path.

A change to the search or the hash alone (/projects?sort=name) is not a new page view. Each call carries the path and two props:

// What the tracker sends when Ada opens her projects page
analytics.page('/projects', { url: 'https://app.acme.com/projects', title: 'Projects · Acme' })
// adapters receive: ('/projects', { app: 'acme', url: 'https://app.acme.com/projects', title: 'Projects · Acme' })

The tracker needs the router that createKitrRouter() from @kitr/router binds into the container. Without one, it sends nothing.

To send page views yourself, turn the automatic ones off:

// config/analytics.ts
import { defineConfig } from '@kitr/analytics'

export default defineConfig({
  adapters: [],
  pageViews: false,
})

Then call page() where you decide a page was seen, for example when a wizard step changes but the URL does not:

// app/components/onboarding/step.tsx
import { useAnalytics } from '@kitr/analytics/react'
import { useEffect } from 'react'

export function OnboardingStep({ step }: { step: 'team' | 'project' | 'invite' }) {
  const analytics = useAnalytics()
  useEffect(() => {
    analytics.page(`/onboarding/${step}`, { title: `Onboarding: ${step}` })
  }, [analytics, step])
  return null
}

If you build the React tree by hand instead of through the provider, render <TrackPageViews /> from @kitr/analytics/react once inside it. It renders nothing and does the same work as the provider's tracker.

Identify a signed-in user

After sign-in, tell the services who the visitor is, so later events join up with their earlier ones. Pass a stable id, never an email address:

// app/components/auth/sign-in-form.tsx
import { useAnalytics } from '@kitr/analytics/react'
import type { AppEvents } from '#app/analytics'

export function useAfterSignIn() {
  const analytics = useAnalytics<AppEvents>()
  return (user: { id: string; plan: 'free' | 'team' }) => {
    analytics.identify(user.id, { plan: user.plan })
    analytics.track('sign-in.completed', { method: 'password' })
  }
}

Call the returned function when Grace's sign-in succeeds: afterSignIn({ id: 'usr_grace', plan: 'team' }). Google Analytics sets user_id and the plan user property, PostHog calls posthog.identify('usr_grace', { plan: 'team' }), and Plausible, which has no user profiles, ignores it. identify() does not add globalProps.

Forget the user on sign-out

Call reset() when the visitor signs out, so the next person on a shared computer is not counted as them:

// app/components/auth/sign-out-button.tsx
import { useAnalytics } from '@kitr/analytics/react'

export function SignOutButton({ onSignOut }: { onSignOut: () => Promise<void> }) {
  const analytics = useAnalytics()
  return (
    <button
      type="button"
      onClick={async () => {
        await onSignOut()
        analytics.reset()
      }}
    >
      Sign out
    </button>
  )
}

Google Analytics clears user_id, PostHog calls posthog.reset(), and adapters without a reset method skip it.

Add props to every event

globalProps in config/analytics.ts are merged into every page() and track() call. Use them for facts about the build, not about the visitor:

// config/analytics.ts
import { defineConfig, plausible } from '@kitr/analytics'
import { env } from '#app/env'

export default defineConfig({
  adapters: [plausible({ domain: 'app.acme.com' })],
  globalProps: { app: env.VITE_APP_NAME, release: '2026.11.1' },
})

A prop on the call wins over a global prop with the same name: track('project.created', { id: 'prj_42', release: 'canary' }) sends release: 'canary'.

Track outside a component

Outside React, read the service from the container. In a store, a query hook or a provider, app.container.use('analytics') returns the same instance the components use:

// app/lib/projects/create-project.ts
import type { Analytics } from '@kitr/analytics'
import type { KitrApp } from 'kitr/app'
import type { AppEvents } from '#app/analytics'

export async function createProject(app: KitrApp, name: string) {
  const response = await fetch('/api/projects', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ name }),
  })
  const project: { id: string } = await response.json()
  const analytics = app.container.use('analytics') as Analytics<AppEvents>
  analytics.track('project.created', { id: project.id })
  return project
}

Don't import googleAnalytics() or window.gtag into feature code to send an event. Go through the service, so consent, queuing and every other adapter still apply.

Adapters

An adapter sends calls to one service. List as many as you like in adapters; every call goes to each of them in order. Each adapter loads its own script or client the first time tracking is enabled in the browser, never on the server.

Adapter Sends to Script or client
googleAnalytics({ measurementId }) Google Analytics 4 Loads gtag.js for you.
plausible({ domain, apiHost }) Plausible, cloud or self-hosted Loads script.manual.js for you.
posthog(client, { apiKey }) PostHog You install posthog-js and pass it in.
consoleAdapter({ prefix }) The browser console None. The development default.
memoryAdapter() An array in memory None. For tests.

Google Analytics

Nothing to install. googleAnalytics() injects the gtag.js script once and turns off gtag's own page views, so the router decides what a page view is.

// config/analytics.ts
import { defineConfig, googleAnalytics } from '@kitr/analytics'
import { env } from '#app/env'

export default defineConfig({
  adapters: env.VITE_GA_MEASUREMENT_ID
    ? [googleAnalytics({ measurementId: env.VITE_GA_MEASUREMENT_ID, debug: false })]
    : [],
})

Then set the id in .env:

VITE_GA_MEASUREMENT_ID=G-ACME12345
Option Default Description
measurementId required The G- id of your GA4 data stream.
debug off Sets gtag's debug_mode, so events show in GA's DebugView.

page() becomes a page_view event with page_path, page_location and page_title. track('project.created', { id }) becomes gtag('event', 'project.created', { id }). GA4 has its own rules for event names; check them in the GA4 event reference before you settle on a naming scheme for an app that sends to GA.

Plausible

Nothing to install. plausible() injects Plausible's manual script (/js/script.manual.js) once, with data-domain set to your site.

// config/analytics.ts
import { defineConfig, plausible } from '@kitr/analytics'

export default defineConfig({
  adapters: [plausible({ domain: 'app.acme.com' })],
})

For a self-hosted Plausible, point apiHost at it. A trailing slash is removed:

// config/analytics.ts
import { defineConfig, plausible } from '@kitr/analytics'

export default defineConfig({
  adapters: [plausible({ domain: 'app.acme.com', apiHost: 'https://stats.acme.com' })],
})
Option Default Description
domain required The site as registered in Plausible.
apiHost https://plausible.io Where the script is loaded from.

page() sends a pageview with the full URL and your props as custom props. track() sends a custom event with props. Calls made before the script loads wait in Plausible's own queue. Plausible has no user profiles, so identify() and reset() do nothing. Create a goal in Plausible for each event name you want to count; see custom event goals.

PostHog

Install the PostHog browser client, then pass it in. kitr does not import posthog-js itself, so it stays out of your bundle until you add it:

pnpm add posthog-js
// config/analytics.ts
import posthogJs from 'posthog-js'
import { defineConfig, posthog } from '@kitr/analytics'
import { env } from '#app/env'

export default defineConfig({
  adapters: env.VITE_POSTHOG_KEY
    ? [
        posthog(posthogJs, {
          apiKey: env.VITE_POSTHOG_KEY,
          apiHost: 'https://eu.i.posthog.com',
          config: { autocapture: false },
        }),
      ]
    : [],
})

Declare the key in app/env.ts:

// app/env.ts
import { createEnv, env as e } from 'kitr/env'

export const env = createEnv({
  public: {
    // ...
    VITE_GA_MEASUREMENT_ID: e.string({ optional: true }),
    VITE_POSTHOG_KEY: e.string({ optional: true }),
  },
  server: {},
})

Then set it in .env:

VITE_POSTHOG_KEY=phc_acme
Option Default Description
apiKey required Your PostHog project API key.
apiHost https://us.i.posthog.com Your PostHog region or proxy.
config none Any other posthog.init() options, merged last.

The adapter calls posthog.init(apiKey, { api_host, capture_pageview: false, ...config }). Automatic page views are off so the router drives them; page() becomes a $pageview capture with $current_url. Any object with init, capture, identify and reset works as the client (the PostHogLike type). See the posthog-js docs for config options.

Console

consoleAdapter() prints each call with console.info. It is what the provider uses in development when adapters is empty. Add it yourself to see calls next to a real service, with a prefix to tell them apart:

// config/analytics.ts
import { consoleAdapter, defineConfig, plausible } from '@kitr/analytics'

export default defineConfig({
  adapters: [plausible({ domain: 'app.acme.com' }), consoleAdapter({ prefix: '[acme]' })],
})
[acme] page /projects { app: 'acme', url: 'https://app.acme.com/projects', title: 'Projects · Acme' }
[acme] track project.created { app: 'acme', id: 'prj_42' }

Memory

memoryAdapter() records every call in its calls array as { type, args }. Use it in tests; see Testing.

In much of the world you need permission before tracking. Set enabled: false and every call waits in a queue instead of going out:

// config/analytics.ts
import { defineConfig, googleAnalytics } from '@kitr/analytics'
import { env } from '#app/env'

export default defineConfig({
  adapters: env.VITE_GA_MEASUREMENT_ID
    ? [googleAnalytics({ measurementId: env.VITE_GA_MEASUREMENT_ID })]
    : [],
  enabled: false,
})

When the visitor agrees, call enable(). It loads each adapter's script, then sends the queued calls in order:

// app/components/tracking-choice.tsx
import { useAnalytics } from '@kitr/analytics/react'
import { useState } from 'react'

export function TrackingChoice() {
  const analytics = useAnalytics()
  const [answered, setAnswered] = useState(analytics.enabled)
  if (answered) return null
  return (
    <div role="dialog" aria-label="Tracking">
      <p>Acme counts page views to improve the app.</p>
      <button
        type="button"
        onClick={() => {
          void analytics.enable()
          setAnswered(true)
        }}
      >
        Allow
      </button>
      <button
        type="button"
        onClick={() => {
          analytics.disable()
          setAnswered(true)
        }}
      >
        No thanks
      </button>
    </div>
  )
}

analytics.enabled is a plain property, not React state, so the component keeps its own answered flag to re-render. This example does not remember the answer across visits; consent stores it in a cookie.

What happens to each call:

  • Before enable(), calls queue. The queue holds 100; later calls are dropped until you enable.
  • enable() resolves once every adapter's init() has run, then the queue is sent. Calling it again does nothing.
  • disable() empties the queue and stops sending. Calls made after it queue again and go out if enable() is called later.
  • analytics.enabled tells you which state you are in.

The consent package does all of this for you: it calls enable() when the visitor grants the analytics category and disable() when they withdraw it. When the consent provider is in kitrrc.ts, the analytics provider starts disabled on its own, wherever the two providers sit in the list and whatever enabled says in config/analytics.ts. No tracker script loads and no vendor cookie is set before the visitor agrees. With consent installed, never call enable() yourself; let the banner decide.

The one exception is analytics: false in config/consent.ts, which tells consent not to manage analytics. Analytics then follows its own enabled option again.

Add an adapter at run time

use() adds an adapter after the app has started. If analytics is already enabled, the adapter's script loads at once; otherwise it loads on enable(). Use it for a service you only want for some visitors, such as session replay for Acme staff:

// app/components/staff/staff-tools.tsx
import { posthog } from '@kitr/analytics'
import { useAnalytics } from '@kitr/analytics/react'
import posthogJs from 'posthog-js'
import { useEffect } from 'react'

export function StaffTools({ email }: { email: string }) {
  const analytics = useAnalytics()
  useEffect(() => {
    if (!email.endsWith('@acme.com')) return
    if (analytics.adapters.some((a) => a.name === 'posthog')) return
    analytics.use(posthog(posthogJs, { apiKey: 'phc_acme_staff' }))
  }, [analytics, email])
  return null
}

An added adapter only receives calls made after it was added. analytics.adapters lists every adapter by name; there is no way to remove one.

Survive blocked scripts

Ad blockers block analytics scripts, and vendors have outages. Every adapter call runs inside a try, and a rejected promise from an adapter is caught, so track() never throws into your click handler and the other adapters still receive the call:

import { createAnalytics, memoryAdapter } from '@kitr/analytics'

const memory = memoryAdapter()
const analytics = createAnalytics({
  adapters: [
    { name: 'blocked', page: () => {}, track: () => Promise.reject(new Error('blocked')) },
    memory,
  ],
})
await analytics.enable()
analytics.track('project.created', { id: 'prj_42' })
// => no error; memory.calls has the track call

With debug on, each failure prints [analytics] blocked Error: blocked as a warning. In production debug is off and failures are silent.

Use it on the server

During server rendering the provider still binds analytics, so components that call useAnalytics() render the same on both sides. Adapters never load their scripts on the server, because init() is skipped when there is no window, and the page view tracker runs in an effect, which the server never runs.

Call track() from event handlers and effects, never during render or in a route loader. On the server such a call still reaches the adapters, which have no script to deliver it: the console adapter prints it to the server log, and the Google Analytics and Plausible adapters push it onto a global queue that nothing reads. To record a server event, such as a paid invoice, write it from the controller to your database or a job.

Testing

Pass memoryAdapter() in config and assert on its calls. Each call is recorded as { type, args }, with globalProps already merged in. This test renders the button from Track your first event inside a test app and checks the event it sends:

// app/components/projects/new-project-button.test.tsx
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { memoryAdapter } from '@kitr/analytics'
import { analyticsProvider } from '@kitr/analytics/provider'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { describe, expect, it } from 'vitest'
import { NewProjectButton } from './new-project-button'

describe('NewProjectButton', () => {
  it('tracks project.created with the new id', async () => {
    const memory = memoryAdapter()
    const app = await createTestApp({
      providers: [analyticsProvider],
      config: { analytics: { adapters: [memory], globalProps: { app: 'acme' } } },
    })
    renderWithKitr(<NewProjectButton onCreate={async () => ({ id: 'prj_42' })} />, { app })

    fireEvent.click(screen.getByRole('button', { name: 'New project' }))

    await waitFor(() =>
      expect(memory.calls).toContainEqual({
        type: 'track',
        args: ['project.created', { app: 'acme', id: 'prj_42' }],
      }),
    )
  })
})

For code outside components, build an instance with createAnalytics() and no app at all. This one checks the consent queue:

// app/lib/projects/analytics.test.ts
import { createAnalytics, memoryAdapter } from '@kitr/analytics'
import { expect, it } from 'vitest'

it('holds events until tracking is enabled', async () => {
  const memory = memoryAdapter()
  const analytics = createAnalytics({ adapters: [memory], enabled: false })

  analytics.track('project.created', { id: 'prj_42' })
  analytics.identify('usr_ada', { plan: 'team' })
  expect(memory.calls).toEqual([])

  await analytics.enable()
  expect(memory.calls).toEqual([
    { type: 'track', args: ['project.created', { id: 'prj_42' }] },
    { type: 'identify', args: ['usr_ada', { plan: 'team' }] },
  ])
})

The assertions you will write most:

Assertion Checks
expect(memory.calls).toContainEqual({ type: 'track', args: ['invoice.paid', expect.objectContaining({ invoiceId: 'inv_7' })] }) An event was sent with a prop, ignoring the others.
expect(memory.calls.filter((c) => c.type === 'page')).toHaveLength(1) One page view.
expect(memory.calls).toContainEqual({ type: 'reset', args: [] }) Sign-out cleared the user.
expect(analytics.enabled).toBe(false) Nothing goes out before consent.

createTestApp() boots in the test mode, so the provider adds no console adapter and nothing is printed.

Extend

An adapter is an object that implements AnalyticsAdapter. Only name, page and track are required:

// The interface, from @kitr/analytics
export interface AnalyticsAdapter {
  name: string
  init?(): void | Promise<void>
  page(path: string, props?: EventProps & { url?: string; title?: string }): void
  track(event: string, props?: EventProps): void
  identify?(userId: string, traits?: EventProps): void
  reset?(): void
}

init() runs once, in the browser, the first time tracking is enabled. Load scripts and create clients there, not when the adapter is created, so nothing loads before consent. The other methods should be quick and must not throw; the service catches errors, but an adapter that throws on every call hides real problems.

This adapter sends Acme's events to its own endpoint with navigator.sendBeacon, which survives the page closing:

// app/analytics/acme-adapter.ts
import type { AnalyticsAdapter, EventProps } from '@kitr/analytics'

export function acmeAdapter(options: { endpoint: string }): AnalyticsAdapter {
  let userId: string | undefined
  const send = (type: string, name: string, props?: EventProps) => {
    const body = JSON.stringify({ type, name, props, userId, at: new Date().toISOString() })
    if (!navigator.sendBeacon(options.endpoint, body)) {
      void fetch(options.endpoint, { method: 'POST', body, keepalive: true })
    }
  }
  return {
    name: 'acme',
    page: (path, props) => send('page', path, props),
    track: (event, props) => send('track', event, props),
    identify(id, traits) {
      userId = id
      send('identify', id, traits)
    },
    reset() {
      userId = undefined
    },
  }
}

Register it in config like any built-in adapter:

// config/analytics.ts
import { defineConfig } from '@kitr/analytics'
import { acmeAdapter } from '#app/analytics/acme-adapter'

export default defineConfig({
  adapters: [acmeAdapter({ endpoint: 'https://app.acme.com/api/events' })],
})

See AnalyticsAdapter in the API reference.

Devtools

With devtools installed, the provider adds a devtools adapter that copies every call into the log under the analytics channel, and an Analytics panel. The panel shows whether tracking is enabled or still queued, the names of the active adapters, and the last 200 calls with their props. Its actions are Enable (consent given), Disable and Clear events, and each one is also in the command palette.

Next steps

  • API reference for every export and type.
  • Consent to hold analytics until visitors agree.
  • Devtools to watch every call while you build.
  • Monitoring to report errors rather than usage.
  • Router for the navigations that become page views.