npm package: @subrite/signal-sdk
Description: Subrite Signal tracking SDK
Author: Subrite
Latest version: 0.3.4

# Subrite Signal Tracking Script & SDK Guide

## 1. Big Picture

### What the tracker does
- Exposes a singleton `window.subriteSignal` when the bundle loads.
- Your site calls `init(tenantId, apiHost?, debug?)` once per page. `apiHost` is optional and defaults to `https://signal-api.subrite.no`.
- After your Subrite OIDC login succeeds, call `identify(userId, profileFields?)`.
- `pageview`, `track`, and `convert` queue events, batch them, and POST to `${apiHost}/tracking/events`.
- `reset()` clears the cached user + queue after logout. Until a new `identify()` runs, nothing is sent.

### Architecture snapshot
```
┌─────────────────────────────────────────────────────────────┐
│                      Your Website / App                     │
│  ┌───────────────────────────────────────────────────┐      │
│  │       window.subriteSignal.init('tenant')         │      │
│  └───────────────────────────────────────────────────┘      │
│                          │                                  │
│  ┌───────────────────────▼───────────────────────────┐      │
│  │            Event Queue (memory)                   │      │
│  │  • pageview / track / convert                     │      │
│  └───────────────────────┬───────────────────────────┘      │
│                          │ auto-flush every 3s / 100 events │
└──────────────────────────┼──────────────────────────────────┘
                           │ batched POST                     ↓
                     ┌────────────────────────────┐      ┌──────────────────────┐
                     │   Subrite Signal API       │ ───▶ │  PostgreSQL storage  │
                     │   POST /tracking/events    │      │  per-tenant isolation│
                     └────────────────────────────┘      └──────────────────────┘
```

### Core concepts
- **Authenticated-only analytics** – events require `identify()`; unauthenticated payloads never leave the browser.
- **Session ID** – `crypto.randomUUID()` (with fallback) per page load.
- **Retry + offline support** – events queue while offline and retry (up to 3x) with exponential backoff.
- **OIDC session hand-off** – NextAuth (or your auth layer) exposes `session.user`. The SDK uses those stable IDs (e.g. `sub`, `member_id`, `subscription_id`).
- **Privacy** – never send PII (emails, names). Use tenant IDs, subscription IDs, or hashed identifiers that your auth flow already provides.

---

## 2. Embedding the hosted script

Include the bundle that Subrite publishes at `http://signal.subrite.no/sdk/subrite-signal.js`:

```html
<script src="http://signal.subrite.no/sdk/subrite-signal.js" defer></script>
```

The script exposes `window.subriteSignal` in browsers as soon as it loads. Simply check for the global before calling `init` (no `SignalSDKReady` event is emitted today):

```html
<script>
  const initialize = () => {
    const sdk = window.subriteSignal;
    if (sdk) {
      sdk.init('tenant-id', 'https://signal-api.subrite.no', true);
    }
  };

  if (window.subriteSignal) {
    initialize();
  } else {
    const observer = new MutationObserver(() => {
      if (window.subriteSignal) {
        observer.disconnect();
        initialize();
      }
    });
    observer.observe(document.documentElement, { childList: true, subtree: true });
  }
</script>
```

## 3. Using the SDK APIs

After `init()` you can:

- `identify(userId, properties?)` – unlock tracking with an authenticated ID.
- `pageview(properties?)`, `track(eventName, properties?)`, and `convert(...)` – send batched events; `identify()` must precede them.
- `reset()` – call on logout to clear identity + queue.
- `flushNow()` and `getDistinctId()` – helpers for QA or manual flushing.

The exported singleton works the same across React/Vue components or classic `<script>` blocks—just reference `window.subriteSignal` once the script has loaded.
---

## 4. Initialising and Identifying Users

### Minimal integration
```html
<script>
  window.addEventListener('DOMContentLoaded', async () => {
    const subriteSignal = window.subriteSignal;
    if (!subriteSignal) {
      console.warn('[Subrite Signal] bundle not available');
      return;
    }

    // API host is optional - defaults to https://signal-api.subrite.no
    subriteSignal.init('tenant-id', undefined, true); // or: subriteSignal.init('tenant-id');

    // Wait for your OIDC session bootstrap
    const session = window.subriteSessionUser; // populate from your app
    if (!session) return;

    const stableUserId =
      session.subscription_id ??
      session.sub ??
      session.member_id ??
      session.analytics_id ??
      session.user_id ??
      session.preferred_username;

    if (!stableUserId) return; // do not fall back to email

    subriteSignal.identify(stableUserId, {
      subscription_status: session.subscription_status,
      subscription_plan: session.subscription_plan,
      member_since: session.created_at,
    });

    subriteSignal.pageview({ section: 'dashboard', logged_in: true });
  });
</script>
```
---
## 5. SDK Lifecycle & Internals

### Key pieces in `packages/sdk/src/index.ts`
- **Singleton state**
  ```typescript
  private queue: any[] = [];
  private retryQueue: any[] = [];
  private userId: string | null = null;
  private sessionId = generateId();
  private flushInterval = 3000;
  private maxBatchSize = 100;
  private maxRetries = 3;
  ```

- **`init`**
  - Stores tenant + host + debug flag.
  - Starts the flush timer.
  - Registers event listeners: `beforeunload`, `visibilitychange`, `online/offline`.
  - Does nothing else until a user authenticates.

- **`identify(userId, properties?)`**
  - Requires the SDK to be initialised.
  - Validates the ID, stores it, and optionally logs metadata.
  - **Does not enqueue any network event**—it only unlocks tracking for authenticated sessions.

- **`pageview`, `track`, `convert`**
  - Abort with an error if `userId` is missing (protects against anonymous tracking).
  - Append the event to the queue with `tenant_id`, `user_id`, `session_id`, current URL, referrer, timestamp.
  - `convert` adds conversion metadata (`conversion_type`, `value`).

- **Queue + flush**
  ```typescript
  enqueue(event) {
    this.queue.push(event);
    if (this.queue.length >= this.maxBatchSize) {
      this.flush();
    }
  }

  startFlushTimer() {
    this.flushTimer = setInterval(() => {
      if (this.queue.length > 0) {
        this.flush();
      }
    }, this.flushInterval);
  }

  async flush(force = false) {
    if (!this.config || this.flushing || this.queue.length === 0) return;
    if (!this.isOnline && !force) return;
    ...
  }
  ```
  - Batches events (`maxBatchSize`).
  - POSTs to `${apiHost}/tracking/events`.
  - On failures, increments `retryCount`, re-queues with exponential backoff, drops after the configured maximum.

- **`reset()`**
  ```typescript
  reset() {
    this.userId = null;
    this.sessionId = generateId();
    this.queue = [];
    this.retryQueue = [];
  }
  ```

---

## 8. Tracking API (Method Reference)

| Method | Purpose | Requires identify? | Notes |
| ------ | ------- | ------------------ | ----- |
| `init(tenantId, apiHost?, debug?)` | Configure SDK and start timers | No | `apiHost` optional, defaults to `https://signal-api.subrite.no`. Call exactly once per page |
| `identify(userId, properties?)` | Unlock tracking for this user | Yes | Caches ID locally; no network event |
| `pageview(properties?)` | Record a view/route change | Yes | Captures URL + referrer automatically |
| `track(eventName, properties?)` | Custom events (CTA, scroll, etc.) | Yes | `event_type: 'custom'` |
| `convert(eventName, conversionType, value?, meta?)` | High-value conversions | Yes | Adds `conversion_type` and optional `value` |
| `reset()` | Clear identity & queue | No | Use on logout |
| `flushNow()` | Force immediate POST | Yes | Useful for QA |
| `getDistinctId()` | Inspect current user ID | N/A | Returns cached `userId` or `null` |

**Reminder:** All user identifiers must originate from authenticated context—never invent anonymous IDs client-side.

---

## 9. Practical Examples

### Pageview + route change (Next.js)
```tsx
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useSession } from 'next-auth/react';

export function RouteAnalytics() {
  const router = useRouter();
  const { data: session, status } = useSession();

  useEffect(() => {
    if (status !== 'authenticated' || !session?.user) return;
    const client = window.subriteSignal;
    if (!client?.getDistinctId?.()) return; // ensure identify ran

    const trackPage = (url: string) =>
      client.pageview({
        section: deriveSection(url),
        member_tier: session.user.subscription_plan,
      });

    trackPage(router.asPath);
    router.events.on('routeChangeComplete', trackPage);
    return () => router.events.off('routeChangeComplete', trackPage);
  }, [router, session, status]);

  return null;
}
```

### CTA click
```typescript
document.querySelector('#buy-now')?.addEventListener('click', () => {
  const client = window.subriteSignal;
  client?.track('cta_clicked', {
    cta_type: 'purchase_button',
    plan_id: 'premium',
    plan_name: 'Premium Plan',
    price: 59.99,
  });
});
```

### Subscription conversion
```typescript
window.subriteSignal?.convert('subscription_purchase', 'subscription', 59.99, {
  plan_id: 'premium',
  billing_cycle: 'monthly',
  payment_method: 'stripe',
  conversion_funnel: 'landing_to_checkout',
});
```

### Logout handling
```typescript
document.querySelector('#logout')?.addEventListener('click', () => {
  const client = window.subriteSignal;
  client?.track('user_logout', { location: 'header_menu' });
  client?.reset();
});
```
---