# Analytics — `@sonordev/site-kit/analytics`

Automatic page view tracking, custom events, conversions, scroll depth, heatmap clicks, and Core Web Vitals. All data flows through the Sonor API.

## Usage

`SiteKitLayout` mounts analytics for you. It renders `{children}` first and
mounts `AnalyticsProvider` after them as a childless sibling, deferred until
window load + idle (or the first interaction). Analytics never sits in the
page's hydration path and can't push a route to client rendering. Configure it
on the layout:

```tsx
// app/layout.tsx (server component)
import { SiteKitLayout } from '@sonordev/site-kit/layout'
import { ContactTracking } from '@sonordev/site-kit/analytics'

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <SiteKitLayout analytics={{ excludePaths: ['/admin'] }}>{children}</SiteKitLayout>
        {/* Optional: tel:/mailto: clicks as conversions. Childless, renders null. */}
        <ContactTracking />
      </body>
    </html>
  )
}
```

Don't wrap `{children}` in `AnalyticsProvider` yourself. It puts analytics in
the hydration path (the page-view code ships in the page's initial scripts),
and if it's lazy-loaded with `next/dynamic({ ssr: false })` the whole route
de-opts to client rendering.

### Without `SiteKitLayout`

A layout that doesn't use core `SiteKitLayout` mounts analytics itself, and
still as a deferred, childless island. Publish the credential server-side
first with `SiteKitCredential` (see `@sonordev/site-kit/client`). Never pass
`SONOR_API_KEY` to a client component.

```tsx
// app/analytics-shell.tsx
'use client'
import { AnalyticsProvider, ContactTracking } from '@sonordev/site-kit/analytics'

export default function AnalyticsShell() {
  return (
    <AnalyticsProvider trackPageViews trackWebVitals>
      <ContactTracking />
    </AnalyticsProvider>
  )
}
```

```tsx
// app/deferred-analytics.tsx
'use client'
import { lazy, Suspense } from 'react'
import { useDeferredActivation } from '@sonordev/site-kit/client'

const AnalyticsShell = lazy(() => import('./analytics-shell'))

export function DeferredAnalytics() {
  const ready = useDeferredActivation(true)
  if (!ready) return null
  return (
    <Suspense fallback={null}>
      <AnalyticsShell />
    </Suspense>
  )
}
```

```tsx
// app/layout.tsx (server component): {children} is a SIBLING of the island, never inside it
import { resolveClientCredential } from '@sonordev/site-kit/server'
import { SiteKitCredential } from '@sonordev/site-kit/client'
import { DeferredAnalytics } from './deferred-analytics'

export default async function RootLayout({ children }) {
  const credential = await resolveClientCredential(process.env.SONOR_API_KEY ?? '', 'https://api.sonor.io')
  return (
    <html lang="en">
      <body>
        <SiteKitCredential apiKey={credential} />
        {children}
        <DeferredAnalytics />
      </body>
    </html>
  )
}
```

`AnalyticsProvider` already mounts `WebVitals` when `trackWebVitals` is on.
Don't add a second `<WebVitals />`, or every metric reports twice.

## Tracking custom events

Under `SiteKitLayout` the provider is childless, so no page component sits
inside it. Use the standalone functions. They need no provider and queue until
the deferred provider mounts:

```tsx
'use client'
import { trackEvent, trackConversion } from '@sonordev/site-kit/analytics'

trackEvent({ name: 'button_click', category: 'engagement', properties: { buttonId: 'cta' } })
trackConversion({ type: 'purchase', value: 99.99, currency: 'USD' })
```

## Hooks

`useAnalytics()` and `useTrackEvent()` read the provider's context, so they
only work in components rendered inside an `AnalyticsProvider` (children of
your own analytics island, for example). Anywhere else they throw. Reach for
the standalone functions above instead of wrapping `{children}` to make a hook
work. `useAnalyticsOptional()` returns `null` instead of throwing.

### useAnalytics()

```tsx
const { trackEvent, trackConversion, sessionId, visitorId } = useAnalytics()
```

### useTrackEvent()

```tsx
const { trackEvent, trackConversion } = useTrackEvent()
```

### useContactTracking()

Auto-tracks `tel:` and `mailto:` link clicks as conversions. Works anywhere: it
uses the standalone dispatch, not the provider's context.

```tsx
const { trackPhoneClick, trackEmailClick } = useContactTracking()
```

## Components

| Component           | Purpose                                                                                          |
| ------------------- | ------------------------------------------------------------------------------------------------ |
| `AnalyticsProvider` | The tracker. `SiteKitLayout` mounts it childless; never wrap `{children}` in it                  |
| `WebVitals`         | Auto-reports LCP, CLS, TTFB, INP, FCP. `AnalyticsProvider` mounts it when `trackWebVitals` is on |
| `ContactTracking`   | Auto-tracks phone/email link clicks. Childless, renders null                                     |

## AnalyticsProvider Props

```ts
interface AnalyticsConfig {
  projectId?: string           // Auto-resolved from API key
  trackPageViews?: boolean     // Default: true
  trackWebVitals?: boolean     // Default: true
  trackScrollDepth?: boolean   // Default: true
  sessionTimeout?: number      // Minutes (default: 30)
  excludePaths?: string[]      // Don't track these paths
  allowInFrame?: boolean       // Default: false — see below
  allowLocalhost?: boolean     // Default: false — local builds report nothing
  debug?: boolean              // Log events to console
}
```

## What Gets Tracked Automatically

- **Page views** — on every route change (path, URL, title, referrer, UTM params, device/browser/OS)
- **Web Vitals** — LCP, CLS, TTFB, INP, FCP with good/needs-improvement/poor ratings
- **Contact clicks** — `tel:` and `mailto:` links tracked as conversions, once `<ContactTracking />` is mounted (it isn't by default)
- **DOM metadata** — full snapshot per page view (meta tags, H1, word count, links, content, FAQs)

## Embedded pages report nothing (`analytics.allowInFrame`)

When this site is loaded inside a **cross-origin iframe**, nothing is sent —
no page views, journey/session rows, scroll depth, heatmap clicks, web vitals,
events or conversions. The visitor is on whoever framed the page, not on this
site, so every metric the frame produces is phantom traffic in the analytics
its owner reads.

A portfolio page that shows a live site in desktop, tablet and mobile frames
is the common case, and `securityHeaders`' `DEFAULT_FRAME_ANCESTORS` permits
it. Without the guard, one visit to that page would record three views of `/`
on the framed site, milliseconds apart, sharing one session and visitor id,
with the portfolio as the referrer. A screenshot laid over the frames hides
the pixels, not the JavaScript.

**Same-origin frames still report.** A site embedding itself (a preview pane,
a print view, an on-domain booking frame) has a real visitor really on that
site and no other tenant to pollute.

Opt back in only when the frame IS the product — a widget or partner-hosted
page deliberately distributed as an embed:

```tsx
<SiteKitLayout analytics={{ allowInFrame: true }}>…</SiteKitLayout>
```

There is deliberately no env var or window global for this. A silent switch
that turns cross-tenant tracking back on is the failure mode, not the feature.

The decision lives in one place — `shared/reporting-gate.ts`, over the frame
primitive in `shared/frame.ts`. Every send in the module routes through it, and
`send-gate.test.ts` fails the build if a new one does not. `isCrossOriginFrame()`
is exported from `@sonordev/site-kit/analytics` if a site needs the same answer
for its own third-party pixels.

## Local builds report nothing (`analytics.allowLocalhost`)

The actual browser hostname controls this gate, even in a production build
with `analytics.site` or `NEXT_PUBLIC_SITE_URL` set to a production domain.
`localhost`, its subdomains, `127.0.0.1`, `[::1]`, and `0.0.0.0` report nothing
by default: page views, events, conversions, sessions, vitals, scroll and
heatmap data, fleet heartbeats, and client-side sitemap registrations all use
`resolveAnalyticsTarget`. Website chat and popups don't mount at all.
Public hosts and same-origin frames on public hosts still report normally.

For intentional local reporting:

```tsx
<SiteKitLayout analytics={{ allowLocalhost: true }}>…</SiteKitLayout>
```

The layout passes this option to AnalyticsProvider, WebVitals, FleetHeartbeat,
SitemapSync, SiteChat and SitePopups. Standalone components and `sendFleetHeartbeat`
accept it too; standalone `trackEvent`/`trackConversion` use their mounted
AnalyticsProvider's options. A local cross-origin frame needs **both** opt-ins.
Neither option bypasses authentication. No environment variable or global
silently enables local reporting.

This is a browser gate: build-time `createSitemap` and Node fleet reporting
still run. It doesn't disable forms, commerce, or Signal. When verifying those
modules locally, point them at a test API as well.

## Environment

Uses `SONOR_API_KEY` (injected by `SiteKitLayout`) or `window.__SITE_KIT_API_KEY__`.

## Multi-site projects (`analytics.site`)

A single Sonor project can host many sub-sites (e.g. one project hosting
example.com + a dozen regional microsites). Every analytics event is
tagged with the sub-site host so the dashboard can roll up + filter per
site without forcing each microsite into its own Sonor project.

`SiteKitLayout` resolves the host in this precedence order:

1. Explicit `analytics.site` config
2. `NEXT_PUBLIC_SITE_URL` host
3. `window.location.host`

```tsx
<SiteKitLayout
  analytics={{ site: 'ohio.example.com' }}
>
  …
</SiteKitLayout>
```

Most projects leave it implicit — `NEXT_PUBLIC_SITE_URL` is already set per
microsite, so the dimension fills in automatically. The dashboard shows a
"Site" picker + a "Sites" tab as soon as ≥2 distinct hosts are detected.
