> ## 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 实验 SDK

Subotiz A/B 实验前端 SDK。在浏览器中拉取实验分组数据、支持按需上报转化事件。

* 浏览器优先，**不依赖任何 UI 框架**（React / Vue / 原生页面均可）
* 原生 TypeScript 支持，类型完整

***

## 通过 CDN 加载

可通过 CDN 引入 SDK。

CDN 模式调用语法：`abSubotiz(方法名, ...参数)`

```html theme={null}
<!-- 1. 同步定义全局代理与方法队列 -->
<script>
  window.abSubotizQueue = window.abSubotizQueue || [];
  window.abSubotiz = window.abSubotiz || function () {
    window.abSubotizQueue.push([].slice.call(arguments));
  };

  // 2. 立即开始排队调用
  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. 异步加载真实 SDK，加载完成后会自动回放队列 -->
<script async src="https://cdn.subotiz.com/ab-sdk-subotiz/v1/ab-sdk-subotiz.min.js"></script>
```

***

## 上报事件

业务事件通过 `track(eventName, properties?)` 上报，常用于实验转化分析（注册完成、按钮点击、自有收入 / LTV 等平台无法自动采集的指标）：

```typescript theme={null}
abSubotiz.track('signup_complete');                                  // 计数 / 去重类指标
abSubotiz.track('add_to_cart', { source: 'cta' });                   // 额外标签随事件透传
abSubotiz.track('ltv', { value: 99.9 });                             // 数值类指标（求和 / 均值）
abSubotiz.track('checkout_success', { value: 99, currency: 'USD' }); // value + 额外标签
```

特性：

* **永远上报、永不丢弃**——SDK 不在客户端做白名单拦截，任意 `eventName` 都会发往后端。是否计入某个实验由后端归因，无需前端关心
* **永远成功返回**，不抛出异常
* 网络失败 / 5xx → 入 `localStorage` 离线队列，回到在线状态时自动重发
* 4xx → 静默丢弃（事件本身不合法）
* `init` 未完成时调用 → 静默跳过

<Warning>
  不要在事件参数中传用户隐私数据（手机号、邮箱、身份证、地址等）。SDK 不做过滤，由调用方自行约束。
</Warning>

### 数值指标：`properties.value`

需要**求和 / 平均值**类聚合的指标（如收入、LTV、停留时长），把数值放在 `properties.value` 上：

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

* `value` 必须是**有限数字**（`typeof === 'number'` 且非 `NaN` / `Infinity`）才会被后端纳入数值聚合。
* 非法的 `value`（字符串 `'99.9'`、`NaN`、`null` 等）不会污染数值统计，会作为普通标签原样透传，由后端按"非数值"降级处理。
* 计数 / 去重类指标（如 `signup_complete`）**无需**传 `value`，直接 `track('signup_complete')` 即可。
* `properties` 中除 `value` 外的其他字段都作为附加标签随事件上报，字段名自定。

### 调试：指标白名单提示

后台为实验声明的指标 key 会随分桶结果下发，SDK 在 **`debug: true`** 时据此做拼写提示：当 `track` 的 `eventName` **不在**任何运行中实验声明的指标里，控制台会 `warn`（提示可能拼写错误 / 大小写不符，并打印当前白名单）。

这**只是调试提示**——事件依然正常上报。生产模式（`debug: false`）下完全无此开销，不影响上报行为。

### 曝光事件

实验首次命中时 SDK 会**自动**上报曝光事件，不需要手动调用。粘性缓存中已存在的实验不重复上报。

***

## Pricing 链接自动注入

实验对象类型为 `price_list` 时，SDK 会在返回的价格表链接中，追加以下 query 参数。业务方拿到的链接已经是注入完毕的：

| 参数             | 值                                              |
| -------------- | ---------------------------------------------- |
| `ab_sbt_sid`   | 当前商家 id                                        |
| `ab_sbt_uid`   | 当前用户 id                                        |
| `ab_sbt_utype` | `'user'` 或 `'anonymous'`                       |
| `ab_sbt_sch`   | 来源渠道（仅当 `init` 传入了合法 `sourceChannel` 时才追加，见下文） |

```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;
}
```

***

## 来源渠道定向 (sourceChannel)

`init` 时可传入 `sourceChannel`，用于让后端按**流量来源**做实验定向分流（例如"仅对来自 facebook 的流量开启某实验"）。其余定向维度（设备、语言、国家、IP）由后端从 HTTP 请求头自动解析，**SDK 不采集任何浏览器指纹 / UA / 地理位置信息**。

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

传入后，SDK 会在两处携带该值：

1. **分桶请求**：写入 `POST /sdk/v1/ab/assign` 请求体的 `attributes.source_channel`，供后端定向规则匹配。
2. **Pricing 链接**：对 `price_list` 类型实验的链接追加 `ab_sbt_sch` query 参数（与上面的值同源）。

### 取值与归一化规则

SDK **不做枚举校验、不做大小写转换**，取值口径（如 `facebook` / `google` / `tiktok`）由您与产品侧自行约定。携带前仅做如下处理：

| 传入值                         | 处理结果                                     |
| --------------------------- | ---------------------------------------- |
| 正常字符串                       | **trim 后**携带（保留原始大小写）                    |
| `undefined` / `null` / 非字符串 | 视为未传，不携带 `source_channel` 字段（不是空串占位）     |
| 空字符串 / 纯空白                  | 视为未传，不携带                                 |
| trim 后长度 ≥ 50               | 视为未传，不携带（`debug: true` 时控制台 `warn` 提示超长） |

<Info>
  **稳定性**：`sourceChannel` 在单次会话内被视为稳定值，仅 `init` 时确定一次。后续不监听变化、不会因渠道变化重新拉取分桶。

  未携带优于截断：超长 / 非法值一律不携带，避免后端拿到被静默改写的值后与您的埋点配置产生偏差。
</Info>

***

## API 参考

### `init(config)`

初始化 SDK。在每个 SDK 实例上**只能调用一次**，重复调用会被忽略。立即返回，不阻塞主线程。

* **参数** `config: SubotizSDKConfig`
* **返回** `void`

#### 配置项

`init(config)` 接受一个 `SubotizSDKConfig` 对象：

| 字段              | 类型        | 默认      | 说明                                                  |
| --------------- | --------- | ------- | --------------------------------------------------- |
| `storeId`       | `string`  | —       | **必填**。商家 ID（Subotiz 后台店铺 access no）                |
| `sourceChannel` | `string`  | —       | 可选。来源渠道，用于后端流量定向。详见 [来源渠道定向](#来源渠道定向-sourcechannel) |
| `timeout`       | `number`  | `30000` | 分桶请求超时毫秒数                                           |
| `debug`         | `boolean` | `false` | 打开调试日志，并允许 `forceVariant` 生效。**生产环境保持关闭**           |

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

注册就绪回调。SDK 已就绪时同步触发，未就绪时异步触发。可重复注册多个。

```typescript theme={null}
abSubotiz.onReady(
  (experiments) => { /* 实验数据已就绪 */ },
  (error) => { /* 网络异常且无缓存 */ },
);
```

* **参数**
  * `onSuccess: (experiments: SubotizExperimentMap) => void`
  * `onError?: (error: ABTestError) => void`
* **返回** `void`

### `waitReady()`

`onReady` 的 Promise 版本。

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

* **返回** `Promise<SubotizExperimentMap>`
* **失败时**：抛出 `ABTestError`（与 `onReady` 的 `onError` 等价）

### `isReady()`

同步检查 SDK 是否已就绪。

* **返回** `boolean`

### `getExperiments()`

同步获取当前所有命中实验。

* **返回** `SubotizExperimentMap | null`
  * `null` —— 未初始化、未就绪、或当前没有任何命中实验
  * `Record<string, SubotizExperiment>` —— 实验映射表，键为 `experimentId`

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

上报自定义业务事件。任意事件都会上报，是否计入实验由后端归因。

```typescript theme={null}
abSubotiz.track('signup_complete');                  // 计数 / 去重
abSubotiz.track('ltv', { value: 99.9, tier: 'pro' }); // 求和 / 均值，value 必须是有限数字
```

* **参数**
  * `eventName: string` —— 事件名
  * `properties?: Record<string, unknown>` —— 事件附加属性。其中 `value`（有限数字）用于求和 / 均值类指标；其余字段作为附加标签透传
* **返回** `void`（永不抛错）
* **行为**
  * 永远上报，不在客户端做白名单丢弃
  * `debug: true` 且 `eventName` 不在后台声明的指标白名单中 → 控制台 `warn` 拼写提示（仅提示，不影响上报）
  * `init` 未完成时调用 → 静默跳过

### `forceVariant(experimentId, variantKey)`

**仅 `debug: true` 时生效**。本地强制覆盖某实验的变体，便于开发期验证不同变体的 UI。

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

* **参数** `experimentId: string`、`variantKey: string`
* **返回** `void`

***

## 错误处理

`onReady(_, onError)` 与 `waitReady()` 失败时返回 `ABTestError`：

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

错误码：

| 错误码                | 含义                         |
| ------------------ | -------------------------- |
| `NETWORK_TIMEOUT`  | 请求超时（弱网）                   |
| `NETWORK_ERROR`    | 网络请求失败（离线、跨域被拦截）           |
| `HTTP_ERROR`       | 后端返回非 2xx                  |
| `INVALID_RESPONSE` | 响应格式异常                     |
| `NOT_INITIALIZED`  | `init` 校验失败（如缺少 `storeId`） |

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

### 降级策略

SDK 内置降级策略，业务方只需关心三件事：

1. **首次渲染走对照组** —— `await waitReady()` 在网络异常时最长会等 `timeout` 秒，不要用它阻塞首屏
2. **`onReady` / `waitReady` 中切换变体** —— 用户视觉冲击最小
3. **`onError` 时保持对照组** —— 不展示空白或异常 UI

| 场景                 | SDK 行为                      |
| ------------------ | --------------------------- |
| 二次访问 + 网络异常        | 自动用 `localStorage` 缓存进入就绪态  |
| 首次访问 + 网络异常        | 触发 `onError`                |
| `track` 时 5xx / 离线 | 入离线队列，`window.online` 时自动重发 |
| `track` 时 4xx      | 静默丢弃                        |

***

## 调试

打开 `debug: true` 后，SDK 会在控制台输出请求与队列状态：

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

| 日志                                                           | 含义                           |
| ------------------------------------------------------------ | ---------------------------- |
| `[ab-sdk] reportExposure: sending N event(s)`                | 曝光上报中                        |
| `[ab-sdk] track: sending N event(s)`                         | 自定义事件上报中                     |
| `[ab-sdk] track event queued (offline)`                      | 事件入离线队列                      |
| `[ab-sdk] 4xx error, track event dropped`                    | 4xx 静默丢弃                     |
| `[ab-sdk] MetricKeyRegistry rebuilt: N keys`                 | 分桶成功后重建指标白名单（含 key 预览）       |
| `[ab-sdk] track('xxx') did not match the metric_keys ...`    | `track` 事件名未命中白名单的拼写提示（仍会上报） |
| `[ab-sdk-subotiz] sourceChannel length N >= 50, omitted ...` | `sourceChannel` 超长被忽略        |
| `[ab-sdk] Forced variant: xxx → yyy`                         | `forceVariant` 调用            |

### 重置 SDK 状态

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

### 本地存储键

SDK 在 `localStorage` 中使用以下键：

| 键名                                           | 内容               |
| -------------------------------------------- | ---------------- |
| `_ab_sdk_uuid`                               | 匿名用户 UUID（跨租户共享） |
| `_ab_sdk_exp_{storeId}_{userId}`             | 实验分配快照（有效期 60 天） |
| `ab_event_queue_{storeId}_{userId}_track`    | 离线事件上报队列         |
| `ab_event_queue_{storeId}_{userId}_exposure` | 离线曝光事件队列         |

***

## 常见问题

### SDK 加载之前业务代码已经渲染了，会拿不到实验数据？

会。让业务代码先渲染对照组，再在 `onReady` / `waitReady` 中切换到变体。粘性缓存保证用户刷新后稳定看到自己的变体（首次访问会有"对照组 → 变体"的瞬时切换）。

### `track` 调用了，后端没收到怎么排查？

按顺序检查：

1. `abSubotiz.isReady()` 是否为 `true`？未就绪时 `track` 静默跳过。
2. 打开 `debug: true`，看控制台是否有 `track: sending` 日志。
3. 看浏览器网络面板有没有 `POST /sdk/v1/ab/events` 请求。
4. 响应是 4xx 还是 5xx？4xx 会被丢弃，5xx 会入队等待重发。
5. 看 `localStorage.ab_event_queue_*_track` 是否积压事件。

### 我传了 `sourceChannel`，但实验定向没生效

按顺序检查：

1. 打开 `debug: true`，看分桶请求体 `attributes.source_channel` 是否带上了您的值。
2. 确认值合法：trim 后非空、长度 \< 50。超长会被忽略（控制台有 `warn`）。
3. 确认值与后台定向规则**完全一致**——SDK 不做大小写转换，`Facebook` ≠ `facebook`。
4. 定向规则匹配在后端完成；命中与否可用 `getExperiments()` 看是否拿到该实验。

### `track` 的 `value` 没有进入求和 / 均值统计

`value` 必须是**有限数字**（`typeof === 'number'` 且非 `NaN` / `Infinity`）才会被纳入数值聚合。常见错误是传入字符串：`track('ltv', { value: '99.9' })`（字符串）会被当成普通标签，不参与 sum / average，应改为 `{ value: 99.9 }`。

### 同一个实验，刷新前后变体不一致

SDK 粘性缓存保证 60 天内变体稳定。出现不一致通常是：

* `localStorage` 被清除（隐私模式 / 用户主动清空）
* 后台开启了**用户强制分配**，会覆盖粘性缓存

### 实验在生产没生效，怎么排查

按顺序检查：

1. `storeId` 是否正确（错误的 storeId 不报错，只是拿不到任何实验）
2. 后台实验是否处于"运行中"状态（暂停 / 草稿状态不会下发）
3. 用户是否命中实验定向规则（设备 / 国家由后端从 HTTP 请求头解析）
4. 用 `getExperiments()` 看返回结果，确认是不是变体决策本身就是对照组

### 我想让某个用户完全不参与实验

SDK 不提供"退出实验"接口。变通方案：业务代码自行判断（如登录态 / 角色），命中条件时不读 `experiments` 直接走默认渲染。

### `getExperiments()` 为什么返回 `null` 而不是空对象

`null` 表示"未就绪"或"无任何命中实验"，便于做存在性判断：

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

避免写 `Object.keys(all).length === 0`。

### React 服务端组件 / Edge Runtime 能用吗

不能。SDK 依赖 `localStorage` / `document.cookie` / `window` 等浏览器接口。

***

## 浏览器兼容性

依赖原生 `fetch` / `Promise` / `localStorage`。支持：

* Chrome / Edge ≥ 最近 2 个大版本
* Safari ≥ 最近 2 个大版本
* Firefox ≥ 最近 2 个大版本

不支持 IE。SDK 不内置任何兼容垫片。
