> ## Documentation Index
> Fetch the complete documentation index at: https://docs.subotiz.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Subotiz A/B Test SDK

Subotiz A/B testing front-end SDK. Fetches experiment assignment data in the browser and supports on-demand conversion event reporting.

* Browser-first, **framework-agnostic** (works with React / Vue / plain HTML pages)
* First-class TypeScript support with complete typings

***

## Loading via CDN

The SDK can be included via CDN.

CDN call syntax: `abSubotiz(methodName, ...args)`

```html theme={null}
<!-- 1. Synchronously define the global proxy and method queue -->
<script>
  window.abSubotizQueue = window.abSubotizQueue || [];
  window.abSubotiz = window.abSubotiz || function () {
    window.abSubotizQueue.push([].slice.call(arguments));
  };

  // 2. Start queueing calls immediately
  abSubotiz('init', {
    storeId: 'your_store_id',
  });
  abSubotiz('onReady', function (experiments) {
    var hero = experiments['homepage_hero_test'];
    if (hero && hero.variantKey === 'variant_b') {
      document.documentElement.dataset.heroVariant = 'b';
    }
  });
</script>

<!-- 3. Asynchronously load the real SDK; the queue is replayed automatically once loaded -->
<script async src="https://cdn.subotiz.com/ab-sdk-subotiz/v1/ab-sdk-subotiz.min.js"></script>
```

***

## Reporting events

Business events are reported via `track(eventName, properties?)`, commonly used for experiment conversion analysis (signup completion, button clicks, your own revenue / LTV, and other metrics the platform cannot collect automatically):

```typescript theme={null}
abSubotiz.track('signup_complete');                                  // count / unique-user metrics
abSubotiz.track('ltv', { value: 99.9 });                             // numeric metrics (sum / average)
```

Characteristics:

* Network failure / 5xx → queued to the `localStorage` offline queue and automatically resent when back online
* 4xx → silently dropped (the event itself is invalid)
* Called before `init` completes → silently skipped

<Warning>
  Do not pass user-private data (phone number, email, ID number, address, etc.) in event properties. The SDK does not filter anything; the caller is responsible for this.
</Warning>

### Numeric metrics: `properties.value`

For metrics that require **sum / average** aggregation (e.g. revenue, LTV, time on page), put the number in `properties.value`:

```typescript theme={null}
abSubotiz.track('ltv', { value: 99.9 });
```

* `value` must be a **finite number** (`typeof === 'number'` and not `NaN` / `Infinity`) to be included in numeric aggregation by the backend.
* An invalid `value` (string `'99.9'`, `NaN`, `null`, etc.) will not pollute numeric statistics; it is passed through as an ordinary tag and treated by the backend with "non-numeric" fallback semantics.
* Count / unique-user metrics (e.g. `signup_complete`) **do not** need a `value`; just call `track('signup_complete')`.

### Debugging: metric whitelist hints

The metric keys declared for experiments in the admin console are delivered together with the assignment result. When **`debug: true`**, the SDK uses them for spell-check hints: if the `eventName` passed to `track` is **not** declared in any running experiment's metrics, the console emits a `warn` (suggesting a possible typo / case mismatch, and printing the current whitelist).

This is **only a debugging hint** — the event is still reported normally. In production mode (`debug: false`) there is zero overhead and no impact on reporting behavior.

### Exposure events

When an experiment is first hit, the SDK **automatically** reports an exposure event — no manual call is required. Experiments already present in the sticky cache are not reported again.

***

## Automatic pricing-link injection

When an experiment object type is `price_list`, the SDK appends the following query parameters to the returned pricing-table link. The link the business receives is already fully injected:

| Parameter      | Value                                                                                      |
| -------------- | ------------------------------------------------------------------------------------------ |
| `ab_sbt_sid`   | Current store id                                                                           |
| `ab_sbt_uid`   | Current user id                                                                            |
| `ab_sbt_utype` | `'user'` or `'anonymous'`                                                                  |
| `ab_sbt_sch`   | Source channel (appended only when a valid `sourceChannel` is passed to `init`; see below) |

```typescript theme={null}
 // → https://checkout.subotiz.com/pricing-v2?ab_sbt_sid=store_123&ab_sbt_uid=user_456&ab_sbt_utype=user
const exp = experiments['pricing_redirect_test'];
if (exp && exp.objectType === 'price_list') {
  window.location.href = exp.config!.link as string;
}
```

***

## Source-channel targeting (sourceChannel)

You can pass `sourceChannel` to `init` to let the backend perform experiment targeting by **traffic source** (e.g. "enable an experiment only for traffic coming from facebook").

```typescript theme={null}
abSubotiz.init({
  storeId: 'your_store_id',
  sourceChannel: 'facebook',
});
```

### Value and normalization rules

The SDK performs **no enum validation and no case conversion**. The set of allowed values (e.g. `facebook` / `google` / `tiktok`) is agreed upon between you and your product team. Before sending, only the following processing is applied:

| Input value                       | Result                                                                                    |
| --------------------------------- | ----------------------------------------------------------------------------------------- |
| Normal string                     | Carried **after `trim`** (original casing preserved)                                      |
| `undefined` / `null` / non-string | Treated as not provided; the `source_channel` field is omitted (not an empty placeholder) |
| Empty string / whitespace only    | Treated as not provided; omitted                                                          |
| Length ≥ 50 after `trim`          | Treated as not provided; omitted (`debug: true` emits a console `warn` for over-length)   |

<Info>
  **Stability**: `sourceChannel` is treated as a stable value within a single session and is determined only once at `init`. The SDK does not watch for changes afterward, nor does it re-fetch assignments when the channel changes.

  Omitting beats truncating: over-length / invalid values are always omitted, to avoid the backend receiving a silently-rewritten value that diverges from your analytics configuration.
</Info>

***

## API reference

### `init(config)`

Initializes the SDK. **Can only be called once** per SDK instance; repeated calls are ignored. Returns immediately and does not block the main thread.

* **Parameter** `config: SubotizSDKConfig`
* **Returns** `void`

#### Configuration options

`init(config)` accepts a `SubotizSDKConfig` object:

| Field           | Type      | Default | Description                                                                                      |
| --------------- | --------- | ------- | ------------------------------------------------------------------------------------------------ |
| `storeId`       | `string`  | —       | **Required.** Store ID (the Subotiz admin store access no)                                       |
| `sourceChannel` | `string`  | —       | Optional. Source channel, used for backend traffic targeting.                                    |
| `timeout`       | `number`  | `30000` | Assignment request timeout in milliseconds                                                       |
| `debug`         | `boolean` | `false` | Enables debug logging and allows `forceVariant` to take effect. **Keep disabled in production.** |

### `onReady(onSuccess, onError?)`

Registers a ready callback. Triggered synchronously if the SDK is already ready, asynchronously otherwise. Multiple callbacks can be registered.

```typescript theme={null}
abSubotiz.onReady(
  (experiments) => { /* experiment data is ready */ },
  (error) => { /* network error and no cache */ },
);
```

* **Parameters**
  * `onSuccess: (experiments: SubotizExperimentMap) => void`
  * `onError?: (error: ABTestError) => void`
* **Returns** `void`

### `waitReady()`

The Promise version of `onReady`.

```typescript theme={null}
const experiments = await abSubotiz.waitReady();
```

* **Returns** `Promise<SubotizExperimentMap>`
* **On failure**: throws `ABTestError` (equivalent to `onReady`'s `onError`)

### `isReady()`

Synchronously checks whether the SDK is ready.

* **Returns** `boolean`

### `getExperiments()`

Synchronously gets all currently-hit experiments.

* **Returns** `SubotizExperimentMap | null`
  * `null` — not initialized, not ready, or there are currently no hit experiments
  * `Record<string, SubotizExperiment>` — experiment map keyed by `experimentId`

### `track(eventName, properties?)`

Reports a custom business event. Any event is reported; whether it counts toward an experiment is attributed by the backend.

```typescript theme={null}
abSubotiz.track('signup_complete');      // count / unique users
abSubotiz.track('ltv', { value: 99.9 }); // sum / average; value must be a finite number
```

* **Parameters**
  * `eventName: string` — event name
  * `properties?: Record<string, unknown>` — additional event properties. Among them, `value` (a finite number) is used for sum / average metrics; all other fields are passed through as additional tags
* **Returns** `void` (never throws)
* **Behavior**
  * Always reports; no client-side whitelist dropping
  * `debug: true` and `eventName` not in the metric whitelist declared in the admin console → console `warn` spell-check hint (hint only; does not affect reporting)
  * Called before `init` completes → silently skipped

### `forceVariant(experimentId, variantKey)`

**Only effective when `debug: true`.** Locally forces a given experiment's variant, convenient for verifying different variant UIs during development.

```typescript theme={null}
abSubotiz.forceVariant('homepage_hero_test', 'variant_b');
```

* **Parameters** `experimentId: string`, `variantKey: string`
* **Returns** `void`

***

## Error handling

`onReady(_, onError)` and `waitReady()` return an `ABTestError` on failure:

```typescript theme={null}
interface ABTestError {
  code: ABTestErrorCode;
  message: string;
  cause?: unknown;
}
```

Error codes:

| Code               | Meaning                                           |
| ------------------ | ------------------------------------------------- |
| `NETWORK_TIMEOUT`  | Request timed out (weak network)                  |
| `NETWORK_ERROR`    | Network request failed (offline, blocked by CORS) |
| `HTTP_ERROR`       | Backend returned a non-2xx status                 |
| `INVALID_RESPONSE` | Malformed response format                         |
| `NOT_INITIALIZED`  | `init` validation failed (e.g. missing `storeId`) |

```typescript theme={null}
abSubotiz.onReady(
  (experiments) => render(experiments),
  (error) => {
    if (error.code === "NOT_INITIALIZED") {
      console.error('invalid init config', error.message);
    }
   // do something
  },
);
```

### Fallback strategy

The SDK has built-in fallback behavior; the business only needs to care about three things:

1. **Render the control group first** — `await waitReady()` can wait up to `timeout` seconds on a network error, so do not use it to block the first paint
2. **Switch variants inside `onReady` / `waitReady`** — minimizes visual disruption for users
3. **Keep the control group on `onError`** — never show blank or broken UI

| Scenario                     | SDK behavior                                               |
| ---------------------------- | ---------------------------------------------------------- |
| Repeat visit + network error | Automatically becomes ready using the `localStorage` cache |
| First visit + network error  | Triggers `onError`                                         |
| `track` with 5xx / offline   | Queued offline and resent automatically on `window.online` |
| `track` with 4xx             | Silently dropped                                           |

***

## Debugging

With `debug: true` enabled, the SDK logs requests and queue status to the console:

```typescript theme={null}
abSubotiz.init({ storeId: 'store_123', debug: true });
```

| Log                                                          | Meaning                                                                   |
| ------------------------------------------------------------ | ------------------------------------------------------------------------- |
| `[ab-sdk] reportExposure: sending N event(s)`                | Reporting exposures                                                       |
| `[ab-sdk] track: sending N event(s)`                         | Reporting custom events                                                   |
| `[ab-sdk] track event queued (offline)`                      | Event added to the offline queue                                          |
| `[ab-sdk] 4xx error, track event dropped`                    | 4xx silently dropped                                                      |
| `[ab-sdk] MetricKeyRegistry rebuilt: N keys`                 | Metric whitelist rebuilt after a successful assignment (with key preview) |
| `[ab-sdk] track('xxx') did not match the metric_keys ...`    | `track` event name did not match the whitelist (still reported)           |
| `[ab-sdk-subotiz] sourceChannel length N >= 50, omitted ...` | `sourceChannel` over-length, omitted                                      |
| `[ab-sdk] Forced variant: xxx → yyy`                         | `forceVariant` called                                                     |

### Resetting SDK state

```typescript theme={null}
Object.keys(localStorage)
  .filter((k) => k.startsWith('_ab_sdk_') || k.startsWith('ab_event_queue_'))
  .forEach((k) => localStorage.removeItem(k));
```

### Local storage keys

The SDK uses the following keys in `localStorage`:

| Key                                          | Content                                     |
| -------------------------------------------- | ------------------------------------------- |
| `_ab_sdk_uuid`                               | Anonymous user UUID (shared across tenants) |
| `_ab_sdk_exp_{storeId}_{userId}`             | Experiment assignment snapshot (60-day TTL) |
| `ab_event_queue_{storeId}_{userId}_track`    | Offline event-reporting queue               |
| `ab_event_queue_{storeId}_{userId}_exposure` | Offline exposure-event queue                |

***

## FAQ

### The business code already rendered before the SDK loaded — won't it miss the experiment data?

Yes. Let the business code render the control group first, then switch to the variant inside `onReady` / `waitReady`. The sticky cache guarantees that users see their variant consistently after refresh (the first visit has a momentary "control → variant" switch).

### I called `track` but the backend did not receive it — how do I troubleshoot?

Check in order:

1. Is `abSubotiz.isReady()` `true`? `track` is silently skipped when not ready.
2. Enable `debug: true` and check the console for a `track: sending` log.
3. Check the browser network panel for a `POST /sdk/v1/ab/events` request.
4. Is the response 4xx or 5xx? 4xx is dropped; 5xx is queued for resend.
5. Check whether `localStorage.ab_event_queue_*_track` has events piling up.

### I passed `sourceChannel`, but experiment targeting didn't take effect

Check in order:

1. Enable `debug: true` and verify the assignment request body's `attributes.source_channel` carries your value.
2. Confirm the value is valid: non-empty after `trim` and length \< 50. Over-length values are omitted (with a console `warn`).
3. Confirm the value matches the admin targeting rule **exactly** — the SDK does not convert case, so `Facebook` ≠ `facebook`.
4. Targeting-rule matching happens on the backend; use `getExperiments()` to check whether the experiment is returned.

### My `track` `value` didn't make it into the sum / average statistics

`value` must be a **finite number** (`typeof === 'number'` and not `NaN` / `Infinity`) to be included in numeric aggregation. A common pitfall is passing a string: `track('ltv', { value: '99.9' })` (a string) is treated as an ordinary tag and not included in sum / average. Use `{ value: 99.9 }` instead.

### The same experiment shows different variants before and after refresh

The sticky cache guarantees variant stability for 60 days. Inconsistency is usually caused by:

* `localStorage` being cleared (private mode / the user clearing it manually)
* **Forced user assignment** being enabled in the admin console, which overrides the sticky cache

### An experiment isn't working in production — how do I troubleshoot?

Check in order:

1. Is `storeId` correct? (A wrong `storeId` does not error; you simply get no experiments.)
2. Is the experiment in the "Running" state in the admin console? (Paused / draft states are not delivered.)
3. Does the user match the experiment's targeting rules? (Device / country are parsed by the backend from the HTTP request headers.)
4. Use `getExperiments()` to inspect the result and confirm whether the variant decision itself is the control group.

### I want a specific user to be completely excluded from experiments

The SDK does not provide an "opt-out" API. Workaround: have your business code decide (e.g. by login state / role) and, when the condition is met, skip reading `experiments` and render the default directly.

### Why does `getExperiments()` return `null` instead of an empty object?

`null` means "not ready" or "no hit experiments", making existence checks easy:

```typescript theme={null}
const all = ab.getExperiments();
if (!all) return renderDefault();
```

This avoids writing `Object.keys(all).length === 0`.

### Can it be used in React Server Components / Edge Runtime?

No. The SDK depends on browser APIs such as `localStorage` / `document.cookie` / `window`.

***

## Browser compatibility

Relies on native `fetch` / `Promise` / `localStorage`. Supports:

* Chrome / Edge ≥ latest 2 major versions
* Safari ≥ latest 2 major versions
* Firefox ≥ latest 2 major versions

IE is not supported. The SDK does not bundle any compatibility shims.
