npm package: @splitlab/js-client
Description: Lightweight JavaScript client SDK for SplitLab A/B testing and analytics
Latest version: 0.18.0

# @splitlab/js-client

Lightweight JavaScript client SDK for SplitLab A/B testing and analytics. Zero dependencies, under 2KB gzipped.

## Installation

```html
<script src="https://splitlab.cc/sdk/splitlab.min.js"></script>
```

Or build from source:

```bash
cd sdk && npm run build
# Outputs ESM + CJS + types to dist/
```

## Quick Start

```javascript
import { SplitLabClient } from '@splitlab/js-client';

// No distinctId needed — SDK auto-generates a persistent device ID
const client = new SplitLabClient({
  apiKey: 'ot_live_abc123',
  baseUrl: 'https://splitlab.cc',
});
await client.initialize();

// A/B testing (< 1ms — local evaluation by default)
const variant = client.getVariant('checkout-btn');
if (variant === 'variant_a') showGreenButton();

// Feature flags
if (client.isFeatureEnabled('dark-mode')) enableDarkMode();

// Event tracking (auto-enriched with browser context, UTM, sessions)
await client.track('purchase', { value: 49.99, currency: 'USD' });

// When user logs in — links anonymous events to the user
await client.identify('user-42');

// Clean up
await client.destroy();
```

## Configuration

```typescript
const client = new SplitLabClient({
  // --- Required ---
  apiKey: string;           // Your org API key (ot_live_...)
  baseUrl: string;          // API server URL

  // --- Identity ---
  distinctId?: string;      // User ID (optional — auto-generates device ID if omitted)

  // --- Evaluation ---
  ingestUrl?: string;              // Ingest server URL (defaults to baseUrl)
  enableLocalEvaluation?: boolean; // Evaluate locally via hashing (default: true)
  configRefreshInterval?: number;  // Config polling interval in ms (default: 30000)
  realtimeUpdates?: boolean;       // SSE for instant config push (default: false)
  onConfigUpdate?: () => void;     // Callback when config changes after refresh

  // --- Event batching ---
  autoFlushInterval?: number;      // ms between auto-flushes (default: 30000)
  autoFlushSize?: number;          // events before auto-flush (default: 20)

  // --- Analytics auto-capture ---
  captureContext?: boolean;        // Browser context enrichment (default: true in browser)
  captureUtm?: boolean;            // UTM parameter capture (default: true)
  trackSessions?: boolean;         // Session ID tracking (default: true)
  sessionTimeout?: number;         // Session inactivity timeout in ms (default: 1800000)
  persistDeviceId?: boolean;       // Persistent device ID in localStorage (default: true)
  trackPageviews?: boolean;        // Auto-track SPA navigations (default: false)
  superProperties?: Record<string, any>; // Properties attached to every event

  // --- Autocapture ---
  trackClicks?: boolean;               // Track $click events (default: false)
  trackRageClicks?: boolean;           // Track $rage_click events (default: false)
  trackSubmits?: boolean;              // Track $submit events (default: false)
  captureAttributes?: CaptureAttributeRule | CaptureAttributeRule[]; // Custom HTML attribute capture
  trackScrollDepth?: boolean;          // Track $scroll_depth events (default: false)
  trackTimeOnPage?: boolean;           // Track $time_on_page events (default: false)
  trackErrors?: boolean;               // Track $error events (default: false)
  trackWebVitals?: boolean;            // Track $web_vitals events (default: false)
});
```

## Local Evaluation (Default)

The SDK defaults to local evaluation — experiments and flags are computed client-side using the same deterministic hashing as the server. On `initialize()`, the SDK fetches the org config once and evaluates locally. Subsequent `getVariant()` and `isFeatureEnabled()` calls return in < 1ms with zero network calls.

```javascript
const client = new SplitLabClient({
  apiKey: 'ot_live_abc123',
  baseUrl: 'https://splitlab.cc',
  // enableLocalEvaluation: true  — this is the default
});
await client.initialize();
```

Config is refreshed every 30 seconds by default. Refreshes use ETag-based conditional requests — if the config hasn't changed, the server returns `304 Not Modified` (< 5ms, no body parsed).

To disable local evaluation and use server-side assignment (which respects sticky assignments in the database):

```javascript
const client = new SplitLabClient({
  // ...
  enableLocalEvaluation: false,
});
```

Trade-off: local evaluation does not respect sticky assignments stored server-side. For first-time users the results are identical. For users who were previously assigned a variant, the server may return a different (stored) result.

## Anonymous Users & Identity

The SDK works out of the box without a user ID. On first visit, a persistent `device_id` is generated and stored in `localStorage`. This device ID is used for:

1. **Experiment bucketing** — variants are assigned based on device ID, so the assignment is stable across the anonymous → logged-in transition (no re-randomization)
2. **Event attribution** — events are tagged with the device ID until `identify()` is called

```javascript
// Anonymous — SDK generates a device ID automatically
const client = new SplitLabClient({
  apiKey: 'ot_live_abc123',
  baseUrl: 'https://splitlab.cc',
});
await client.initialize();

// User sees variant B (bucketed on device ID)
const variant = client.getVariant('checkout-btn'); // → 'variant_b'

// Later, user logs in
await client.identify('user-42');

// Still variant B — bucketing is device-level, not user-level
client.getVariant('checkout-btn'); // → 'variant_b' (same)

// Events now attributed to 'user-42' instead of the device ID
await client.track('purchase', { value: 49.99 });
```

When `identify()` is called, the SDK:
- Fires a `$identify` event linking `device_id` → `user_id` (with `previous_distinct_id`)
- Switches event attribution to the new user ID
- Does **not** re-evaluate experiments (bucketing stays on device ID)

You can also provide `distinctId` upfront if the user is already authenticated:

```javascript
const client = new SplitLabClient({
  apiKey: 'ot_live_abc123',
  baseUrl: 'https://splitlab.cc',
  distinctId: currentUser.id, // Already logged in
});
```

## Realtime Config Updates

For instant config propagation (< 1 second after a dashboard edit), enable SSE:

```javascript
const client = new SplitLabClient({
  // ...
  realtimeUpdates: true,
  onConfigUpdate: () => {
    console.log('Config updated — flags and experiments may have changed');
  },
});
```

The SDK opens an EventSource connection to the API's SSE endpoint. When an experiment or flag is created, updated, or deleted, the server pushes a `config_updated` event and the SDK refreshes automatically. EventSource handles reconnection natively.

## Analytics Auto-Capture

Every `track()` call is automatically enriched with contextual properties (in browser environments):

**Browser context** (`captureContext: true`):
- `pathname`, `hostname`, `referrer`, `search_params`, `page_title`, `hash`
- `browser`, `browser_version`, `os`, `os_version`, `device_type` (parsed from User-Agent)
- `screen_width`, `screen_height`, `viewport_width`, `viewport_height`
- `language`, `timezone`

**UTM parameters** (`captureUtm: true`):
- `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`
- Captured on first page load and persisted in `sessionStorage` across SPA navigations

**Session tracking** (`trackSessions: true`):
- `session_id` — random ID that resets after 30 minutes of inactivity (configurable via `sessionTimeout`)

**Device ID** (`persistDeviceId: true`):
- `device_id` — persistent random ID stored in `localStorage`, survives across sessions

**Auto-pageview tracking** (`trackPageviews: true`):
- Wraps `history.pushState`, `history.replaceState`, and the `popstate` event
- Fires a `$pageview` event on each SPA navigation with `previous_url`

**Super properties** (`superProperties`):
- Key-value pairs attached to every event, overriding auto-captured values
- Modify at runtime with `setSuperProperties()` and `unsetSuperProperty()`

Property merge order (last wins): browser context < UTM < session < device < super properties < user properties.

## Custom Attribute Capture

When `trackClicks` or `trackSubmits` is enabled, `captureAttributes` lets you enrich `$click`, `$rage_click`, and `$submit` events with custom HTML attributes from the target element and its ancestors. This lets you integrate existing `data-*` attributes from your markup without code changes.

```typescript
type CaptureAttributeRule =
  | string                                    // Prefix mode
  | { name: string; parse?: (value: string) => Record<string, any> };  // Exact mode
```

### Prefix mode

Match all attributes starting with a prefix. The prefix is stripped to produce the property key.

```typescript
const client = new SplitLabClient({
  apiKey: 'ot_live_abc123',
  baseUrl: 'https://splitlab.cc',
  trackClicks: true,
  captureAttributes: 'data-sl-',
});
```

```html
<section data-sl-section="hero">
  <button data-sl-action="signup" data-sl-tier="pro">Sign Up</button>
</section>
<!-- Click on button → $click { section: "hero", action: "signup", tier: "pro" } -->
```

### Exact attribute with parser

Match a single attribute name and transform its value with a custom function.

```typescript
captureAttributes: {
  name: 'data-track',
  parse: (v) => { try { return JSON.parse(v); } catch { return { track_label: v }; } }
}
```

```html
<div data-track='{"name":"click_banner","campaign":"summer"}'>Click</div>
<!-- → $click { name: "click_banner", campaign: "summer" } -->
```

### Exact attribute without parser

Captures the raw attribute value. Boolean/empty attributes resolve to `true`.

```typescript
captureAttributes: { name: 'data-track-element' }
```

```html
<button data-track-element>Go</button>
<!-- → $click { "data-track-element": true } -->

<button data-track-element="cta">Go</button>
<!-- → $click { "data-track-element": "cta" } -->
```

### Multiple rules

Combine prefix and exact rules for pages with mixed conventions.

```typescript
captureAttributes: [
  'data-sl-',
  { name: 'data-track', parse: (v) => JSON.parse(v) },
  { name: 'data-track-element' },
]
```

### DOM walking

Attributes are collected from the target element up through ancestors to `<body>` (max 10 levels). Child values take priority over parent values on key collision. Captured properties are merged into the event alongside `element_tag`, `element_text`, etc. They flow through the standard pipeline into the `extra` JSON column (or mapped `dim_*` columns if custom dimensions are configured).

For `$submit` events, the SDK also captures `form_action` (the form's action URL) and `form_method` (GET/POST).

## Resilient Initialization

If the config fetch fails during `initialize()` and no cached config exists, the SDK falls back to safe defaults instead of throwing:
- All experiments return `null` (control)
- All flags return `false` (off)
- A warning is logged

If `refresh()` fails, the existing config is kept.

## Direct Ingest URL

In development or when the ingest service runs on a separate host, use `ingestUrl`:

```javascript
const client = new SplitLabClient({
  apiKey: 'ot_live_abc123',
  baseUrl: 'http://localhost:3001',    // API server
  ingestUrl: 'http://localhost:3002',  // Ingest server
});
```

In production behind a reverse proxy (Caddy, nginx), both services share one origin and `ingestUrl` is not needed.

## API

### `client.initialize(): Promise<void>`
Fetches config (or evaluation data), starts the auto-flush timer and config polling timer. If `realtimeUpdates` is enabled, opens the SSE connection.

### `client.getVariant(experimentKey): string | null`
Returns the assigned variant key, or `null` if excluded. < 1ms with local evaluation.

### `client.isFeatureEnabled(flagKey): boolean`
Returns whether the feature flag is enabled for this user. < 1ms with local evaluation.

### `client.track(eventName, properties?): Promise<void>`
Queues an event (auto-enriched with context). Auto-flushes when queue reaches `autoFlushSize`.

### `client.trackPageview(properties?): Promise<void>`
Shorthand for `track('$pageview', properties)`.

### `client.flush(): Promise<void>`
Sends all queued events to the ingest service.

### `client.identify(distinctId, properties?): Promise<void>`
Links the anonymous device to a user ID. Fires a `$identify` event with `previous_distinct_id`. Experiment assignments are unchanged (bucketed on device ID). Future events are attributed to the new user ID.

### `client.getDistinctId(): string`
Returns the current distinct ID (user ID if identified, device ID otherwise).

### `client.getDeviceId(): string`
Returns the stable device ID used for experiment bucketing.

### `client.refresh(): Promise<void>`
Re-fetches config from the server. Uses ETag — returns immediately on 304.

### `client.setAttributes(attributes): void`
Merges attributes for targeting rule evaluation.

### `client.setSuperProperties(props): void`
Sets properties attached to every subsequent event.

### `client.unsetSuperProperty(key): void`
Removes a super property by key.

### `client.destroy(): Promise<void>`
Flushes remaining events, stops all timers, closes SSE connection.

## React

For React bindings (provider + hooks), see [`@splitlab/react`](../react-sdk/).