@macroui/event-tracker 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +343 -0
- package/bin/event-tracker-suggest.mjs +141 -0
- package/bin/event-tracker.mjs +157 -0
- package/dist/event-tracker.cjs +3449 -0
- package/dist/event-tracker.js +3455 -0
- package/dist/event-tracker.min.js +1 -0
- package/dist/event-tracker.mjs +3402 -0
- package/dist/plugin/auto-track.cjs +201 -0
- package/dist/plugin/auto-track.mjs +199 -0
- package/dist/plugin/debug-sender.cjs +214 -0
- package/dist/plugin/debug-sender.mjs +211 -0
- package/dist/plugin/encrypt.cjs +284 -0
- package/dist/plugin/encrypt.mjs +275 -0
- package/dist/plugin/exposure.cjs +121 -0
- package/dist/plugin/exposure.mjs +119 -0
- package/dist/plugin/indexeddb-sender.cjs +94 -0
- package/dist/plugin/indexeddb-sender.mjs +92 -0
- package/dist/plugin/pageleave.cjs +74 -0
- package/dist/plugin/pageleave.mjs +72 -0
- package/dist/plugin/pageload.cjs +198 -0
- package/dist/plugin/pageload.mjs +196 -0
- package/dist/plugin/session-event.cjs +142 -0
- package/dist/plugin/session-event.mjs +140 -0
- package/dist/plugin/webview-bridge.cjs +111 -0
- package/dist/plugin/webview-bridge.mjs +109 -0
- package/dist/types/core/env.d.ts +10 -0
- package/dist/types/core/errors.d.ts +76 -0
- package/dist/types/core/failed-queue.d.ts +24 -0
- package/dist/types/core/identity-api.d.ts +6 -0
- package/dist/types/core/identity.d.ts +39 -0
- package/dist/types/core/instance-channel.d.ts +34 -0
- package/dist/types/core/lifecycle.d.ts +26 -0
- package/dist/types/core/logger.d.ts +13 -0
- package/dist/types/core/plugin.d.ts +60 -0
- package/dist/types/core/profile-api.d.ts +6 -0
- package/dist/types/core/profile-store.d.ts +23 -0
- package/dist/types/core/register-api.d.ts +22 -0
- package/dist/types/core/retry-policy.d.ts +33 -0
- package/dist/types/core/send-pipeline.d.ts +55 -0
- package/dist/types/core/signed-identity.d.ts +29 -0
- package/dist/types/core/stage.d.ts +48 -0
- package/dist/types/core/track-api.d.ts +6 -0
- package/dist/types/core/tracker.d.ts +130 -0
- package/dist/types/core/uuid.d.ts +4 -0
- package/dist/types/index.d.ts +40 -0
- package/dist/types/plugins/auto-track/index.d.ts +21 -0
- package/dist/types/plugins/debug-sender/index.d.ts +24 -0
- package/dist/types/plugins/encrypt/index.d.ts +56 -0
- package/dist/types/plugins/exposure/index.d.ts +18 -0
- package/dist/types/plugins/indexeddb-sender/index.d.ts +15 -0
- package/dist/types/plugins/pageleave/index.d.ts +12 -0
- package/dist/types/plugins/pageload/index.d.ts +14 -0
- package/dist/types/plugins/session-event/index.d.ts +14 -0
- package/dist/types/plugins/webview-bridge/index.d.ts +33 -0
- package/dist/types/presets/preset-properties.d.ts +25 -0
- package/dist/types/send/ajax-sender.d.ts +11 -0
- package/dist/types/send/batch-sender.d.ts +17 -0
- package/dist/types/send/beacon-sender.d.ts +10 -0
- package/dist/types/send/encode.d.ts +8 -0
- package/dist/types/send/image-sender.d.ts +10 -0
- package/dist/types/send/sender.d.ts +20 -0
- package/dist/types/storage/indexeddb-store.d.ts +14 -0
- package/dist/types/storage/store.d.ts +56 -0
- package/dist/types/types/common.d.ts +26 -0
- package/dist/types/types/config.d.ts +117 -0
- package/dist/types/types/constants.d.ts +110 -0
- package/dist/types/types/index.d.ts +25 -0
- package/dist/types/types.test-d.d.ts +5 -0
- package/dist/types.test-d.ts +103 -0
- package/package.json +218 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 macroui
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# @macroui/event-tracker
|
|
2
|
+
|
|
3
|
+
> 自研的 Web 埋点 SDK,参考神策 sa-sdk-javascript 的 API 风格
|
|
4
|
+
>
|
|
5
|
+
> 4 种发送方式 / 全埋点 / WebView 桥 / 加密插件 —— 全 TypeScript 源码,全 Jest 单测
|
|
6
|
+
|
|
7
|
+
[](.)
|
|
8
|
+
[](.)
|
|
9
|
+
[](.)
|
|
10
|
+
[](.)
|
|
11
|
+
|
|
12
|
+
## 特性
|
|
13
|
+
|
|
14
|
+
- **多发送方式**:`image` / `beacon` / `ajax` / `batch`,自动降级
|
|
15
|
+
- **多 sink 上报**:`server_url: string | string[]`
|
|
16
|
+
- **预置事件**:`$pageview` / `$WebClick` / `$WebStay` / `$SignUp` / `$BindID` / `$UnbindID`
|
|
17
|
+
- **公共属性**:`$lib` / `$app_id` / `$screen_width` / `$viewport_width` / `$url` / `$referrer` / `$latest_utm_*` ...
|
|
18
|
+
- **用户身份**:`distinct_id` / `anonymous_id` / `first_id` / `login_id` / `original_id`
|
|
19
|
+
- **3 阶段拦截器**:`buildDataStage` / `businessStage` / `sendDataStage`,priority 排序
|
|
20
|
+
- **7 生命周期钩子**:`sdkReady` / `sdkInitPara` / `sdkAfterInitPara` / `sdkInitAPI` / `sdkAfterInitAPI` / `changeDistinctId` / `switch`
|
|
21
|
+
- **加密插件**:XOR+Base64 / **AES-GCM(Web Crypto)**
|
|
22
|
+
- **WebView 桥**:vapph5 协议,自动取 `app_version` / `app_id` / `channel`
|
|
23
|
+
- **失败重试**:指数退避 1s→2s→4s→30s cap
|
|
24
|
+
- **失败队列**:localStorage 暂存,断网时延后上报
|
|
25
|
+
- **跨实例通信**:`BroadcastChannel`(localStorage 兜底)
|
|
26
|
+
- **签名身份**:`HMAC-SHA256` 防 cookie 篡改
|
|
27
|
+
- **13 个官方插件**:pageload / pageleave / exposure / session-event / debug-sender / indexeddb-sender / auto-track / webview-bridge / encrypt / etc.
|
|
28
|
+
- **TypeScript**:完整 .d.ts,IDE 提示
|
|
29
|
+
- **多格式**:`ESM` / `CJS` / `UMD` / `UMD-min`
|
|
30
|
+
- **零依赖**:核心 SDK 无第三方依赖
|
|
31
|
+
- **测试**:Jest 90 测试 / 15 套件 + fast-check 属性测试 + tsd 类型测试
|
|
32
|
+
- **覆盖率**:51% lines(开发期阈值:30%)
|
|
33
|
+
|
|
34
|
+
## 快速开始
|
|
35
|
+
|
|
36
|
+
### 安装
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install @macroui/event-tracker
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 初始化(UMD)
|
|
43
|
+
|
|
44
|
+
```html
|
|
45
|
+
<script src="https://cdn.jsdelivr.net/npm/@macroui/event-tracker/dist/event-tracker.min.js"></script>
|
|
46
|
+
<script>
|
|
47
|
+
eventTracker.init({
|
|
48
|
+
app_id: 'demo',
|
|
49
|
+
server_url: 'https://api.example.com/sa',
|
|
50
|
+
send_type: 'beacon', // 'image' | 'beacon' | 'ajax' | 'batch'
|
|
51
|
+
debug: 1, // 0=关闭 1=basic 2=verbose
|
|
52
|
+
});
|
|
53
|
+
eventTracker.track('user_login', { method: 'email' });
|
|
54
|
+
</script>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### ESM
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { EventTracker, pageLoad, sessionEvent } from '@macroui/event-tracker';
|
|
61
|
+
|
|
62
|
+
const sdk = new EventTracker();
|
|
63
|
+
sdk.init({
|
|
64
|
+
app_id: 'demo',
|
|
65
|
+
server_url: 'https://api.example.com/sa',
|
|
66
|
+
send_type: 'ajax',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
sdk.use(pageLoad());
|
|
70
|
+
sdk.use(sessionEvent());
|
|
71
|
+
|
|
72
|
+
sdk.track('button_click', { btn_id: 'submit' });
|
|
73
|
+
sdk.setProfile({ $city: 'Beijing', $plan: 'pro' });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 多 sink / 多实例
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
sdk.switchServer(['https://primary.example.com/sa', 'https://backup.example.com/sa']);
|
|
80
|
+
|
|
81
|
+
const tracker2 = initPara({
|
|
82
|
+
app_id: 'demo',
|
|
83
|
+
server_url: 'https://api2.example.com/sa',
|
|
84
|
+
client_id: 'm2',
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## API 一览
|
|
89
|
+
|
|
90
|
+
### 事件
|
|
91
|
+
|
|
92
|
+
| API | 说明 |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `track(event, props, cb?)` | 自定义事件 |
|
|
95
|
+
| `trackSignup(userId, props?, cb?)` | 注册即触发($SignUp) |
|
|
96
|
+
| `trackLinkage(props, cb?)` | 关联事件 |
|
|
97
|
+
| `trackLink(el, props?, cb?)` | 单链接点击 |
|
|
98
|
+
| `trackLinks(els, props?, cb?)` | 多链接点击 |
|
|
99
|
+
| `trackHeatMap(props, cb?)` | 热力图事件 |
|
|
100
|
+
| `trackWebClick(props, cb?)` | $WebClick |
|
|
101
|
+
| `trackWebStay(props, cb?)` | $WebStay |
|
|
102
|
+
| `alias(newId, originalId?)` | 合并身份 |
|
|
103
|
+
|
|
104
|
+
### Profile
|
|
105
|
+
|
|
106
|
+
| API | 说明 |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `setProfile(props, cb?)` | 设置 |
|
|
109
|
+
| `setOnceProfile(props, cb?)` | 仅首次 |
|
|
110
|
+
| `incrementProfile(props, cb?)` | 累加 |
|
|
111
|
+
| `appendProfile(props, cb?)` | 追加 |
|
|
112
|
+
| `unsetProfile(prop, cb?)` | 取消 |
|
|
113
|
+
| `deleteProfile(cb?)` | 删除 |
|
|
114
|
+
|
|
115
|
+
### 身份
|
|
116
|
+
|
|
117
|
+
| API | 说明 |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `identify(id, save=true)` | 设置 distinct_id |
|
|
120
|
+
| `login(id)` | 登录 |
|
|
121
|
+
| `logout(newAnon?)` | 登出 + 重置匿名 |
|
|
122
|
+
| `resetAnonymousIdentity(newAnon?)` | 同 logout |
|
|
123
|
+
| `getDistinctId()` | 当前 distinct_id |
|
|
124
|
+
| `getAnonymousId()` | 匿名 ID |
|
|
125
|
+
| `getFirstId()` | 首次访问 ID |
|
|
126
|
+
| `getLoginId()` | 登录 ID |
|
|
127
|
+
| `getOriginalId()` | 登录前 ID |
|
|
128
|
+
|
|
129
|
+
### 公共属性
|
|
130
|
+
|
|
131
|
+
| API | 说明 |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `register(props)` | 永久属性 |
|
|
134
|
+
| `registerOnce(props)` | 一次性 |
|
|
135
|
+
| `registerPage(props)` | 页面(一次性) |
|
|
136
|
+
| `registerSession(props)` | 会话(永久) |
|
|
137
|
+
| `registerSessionOnce(props)` | 会话(一次性) |
|
|
138
|
+
| `clearAllRegister()` | 清空 |
|
|
139
|
+
| `unsetRegister(key)` | 单个清空 |
|
|
140
|
+
|
|
141
|
+
### 控制
|
|
142
|
+
|
|
143
|
+
| API | 说明 |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `flush()` | 强制 flush batch + failed queue |
|
|
146
|
+
| `waitUntilIdle()` | 等待所有 in-flight 完成 |
|
|
147
|
+
| `abort(reason?)` | Abort 所有挂起任务 |
|
|
148
|
+
| `close()` / `shutdown()` | 完整关闭 |
|
|
149
|
+
| `disable()` / `enable()` | 禁用 / 启用 |
|
|
150
|
+
| `setServerURL(url)` | 切换 sink |
|
|
151
|
+
| `use(plugin)` | 注册插件 |
|
|
152
|
+
| `getPresetProperties()` | 当前 preset |
|
|
153
|
+
|
|
154
|
+
## 插件
|
|
155
|
+
|
|
156
|
+
13 个官方插件,按场景分组:
|
|
157
|
+
|
|
158
|
+
### 自动采集
|
|
159
|
+
- `pageLoad()` — $WebPageLoad(性能 API)
|
|
160
|
+
- `pageLeave()` — $WebPageLeave(visibilitychange)
|
|
161
|
+
- `exposure(opts)` — $element_exposure(IntersectionObserver)
|
|
162
|
+
- `sessionEvent(opts)` — $session_start / $session_end
|
|
163
|
+
- `autoTrack()` — pageview / webclick / webstay
|
|
164
|
+
|
|
165
|
+
### 发送增强
|
|
166
|
+
- `debugSender(opts)` — debug 模式(GET 到 ?debug URL + alert)
|
|
167
|
+
- `indexedDBSender(opts)` — 写到 IDB outbox
|
|
168
|
+
- `webviewBridge()` — vapph5 协议
|
|
169
|
+
|
|
170
|
+
### 安全 / 隐私
|
|
171
|
+
- `encryptor({ encrypt, decrypt?, encryptEvent?, decryptEvent? })` — 通用加密
|
|
172
|
+
- `xorEncryptor(key)` — XOR+Base64 便捷工厂
|
|
173
|
+
- `aesGcmEncryptAsync(key)` / `aesGcmDecryptAsync(key)` — AES-GCM
|
|
174
|
+
|
|
175
|
+
## Plugin Authoring Guide
|
|
176
|
+
|
|
177
|
+
写自定义插件非常简单:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import type { Plugin, InstallContext } from '@macroui/event-tracker';
|
|
181
|
+
|
|
182
|
+
export function myPlugin(): Plugin {
|
|
183
|
+
return {
|
|
184
|
+
name: 'my-plugin',
|
|
185
|
+
install({ stages, config, sdk }: InstallContext) {
|
|
186
|
+
// 注册阶段拦截器
|
|
187
|
+
stages.sendDataStage.register({
|
|
188
|
+
name: 'my-modifier',
|
|
189
|
+
priority: 50, // 越大越晚
|
|
190
|
+
entry(ctx) {
|
|
191
|
+
// 修改 ctx.data(事件或属性)
|
|
192
|
+
if (ctx.data && ctx.data.properties) {
|
|
193
|
+
ctx.data.properties.$my_plugin = 'ran';
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
uninstall({ config }) {
|
|
199
|
+
// 清理工作:删除 config 字段、解除事件监听等
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
sdk.use(myPlugin());
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### 阶段(stage)约定
|
|
208
|
+
|
|
209
|
+
| 阶段 | 用途 | 推荐 priority |
|
|
210
|
+
|---|---|---|
|
|
211
|
+
| `buildDataStage` | 事件构造后,encrypt / send 之前 | 1-50(低) |
|
|
212
|
+
| `businessStage` | 业务校验 / 转换 / 风控 | 50-150 |
|
|
213
|
+
| `sendDataStage` | 实际发送 / 加密 / 落盘 | 100+(高) |
|
|
214
|
+
|
|
215
|
+
### 生命周期钩子
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
install({ sdk }) {
|
|
219
|
+
sdk.getRegistry().on('sdkReady', (cfg) => { /* cfg: TrackerConfig */ });
|
|
220
|
+
sdk.getRegistry().on('changeDistinctId', (id) => { /* 用户切换身份 */ });
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
钩子列表:`sdkReady` / `sdkInitPara` / `sdkAfterInitPara` / `sdkInitAPI` / `sdkAfterInitAPI` / `changeDistinctId` / `switch`
|
|
225
|
+
|
|
226
|
+
## 加密 / 解密
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
import { encryptor, aesGcmEncryptAsync, aesGcmDecryptAsync } from '@macroui/event-tracker';
|
|
230
|
+
|
|
231
|
+
sdk.use(encryptor({
|
|
232
|
+
encrypt: aesGcmEncryptAsync('my-32-byte-secret-key-1234567890ab'),
|
|
233
|
+
decrypt: aesGcmDecryptAsync('my-32-byte-secret-key-1234567890ab'),
|
|
234
|
+
encryptEvent: (e) => {
|
|
235
|
+
// 脱敏:去掉 PII 字段
|
|
236
|
+
if (e.properties?.phone) delete e.properties.phone;
|
|
237
|
+
return e;
|
|
238
|
+
},
|
|
239
|
+
}));
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
> ⚠️ `aesGcmEncrypt` 同步版本仅做 API 完整性占位(Web Crypto 是异步的);
|
|
243
|
+
> 真同步场景请用 XOR(`xorEncryptor`)。
|
|
244
|
+
|
|
245
|
+
## 失败处理
|
|
246
|
+
|
|
247
|
+
SDK 包含三层失败保护:
|
|
248
|
+
|
|
249
|
+
1. **重试**:`max_retry_count` × 指数退避(默认 3 次)
|
|
250
|
+
2. **失败队列**:失败的事件入 localStorage,下次启动重发
|
|
251
|
+
3. **`flush()`**:手动强制 flush batch + failed queue
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
sdk.track('important_event');
|
|
255
|
+
await sdk.waitUntilIdle(); // 等待所有 in-flight
|
|
256
|
+
|
|
257
|
+
// 出错捕获
|
|
258
|
+
sdk.track('foo', {}, (err) => {
|
|
259
|
+
if (err) console.error('send failed:', err);
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## 从神策 v1.27 / v2.0 迁移
|
|
264
|
+
|
|
265
|
+
| 神策 API | event-tracker | 状态 |
|
|
266
|
+
|---|---|---|
|
|
267
|
+
| `init(config)` | `init(config)` | ✅ 完全兼容字段名 |
|
|
268
|
+
| `track(name, props)` | `track(name, props)` | ✅ |
|
|
269
|
+
| `trackSignup(userId)` | `trackSignup(userId)` | ✅ |
|
|
270
|
+
| `profileSet(props)` | `setProfile(props)` | ✅ 别名 `setProfile` |
|
|
271
|
+
| `profileSetOnce(props)` | `setOnceProfile(props)` | ✅ |
|
|
272
|
+
| `register(props)` | `register(props)` | ✅ |
|
|
273
|
+
| `initPara(config)` | `initPara(config)` | ✅ |
|
|
274
|
+
| `registerAppid(id)` | `registerAppid(id)` | ✅ |
|
|
275
|
+
| `alias(newId)` | `alias(newId)` | ✅ |
|
|
276
|
+
| `trackLinkage` / `trackLink` / `trackLinks` | 同名 | ✅ |
|
|
277
|
+
| `trackHeatMap` / `trackWebClick` / `trackWebStay` | 同名 | ✅ |
|
|
278
|
+
| `debug_mode` 集成 | `debugSender({ url })` 单独插件 | ⚠️ 需 use |
|
|
279
|
+
| 38 个官方插件 | 13 个核心插件 | ⚠️ 关键已覆盖 |
|
|
280
|
+
| 加密 `encode` | `encryptor({ encrypt })` | ⚠️ 插件化,需 use |
|
|
281
|
+
|
|
282
|
+
## 开发
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
git clone https://github.com/macroui/event-tracker
|
|
286
|
+
cd event-tracker
|
|
287
|
+
npm install
|
|
288
|
+
|
|
289
|
+
# 开发
|
|
290
|
+
npm run typecheck # tsc --noEmit
|
|
291
|
+
npm run lint # eslint
|
|
292
|
+
npm test # Jest
|
|
293
|
+
npm run test:cov # 覆盖率
|
|
294
|
+
npm run test:bench # 性能基准
|
|
295
|
+
npm run test:types # 类型测试(tsd)
|
|
296
|
+
|
|
297
|
+
# 构建
|
|
298
|
+
npm run build # rollup + tsc-dts
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## 架构
|
|
302
|
+
|
|
303
|
+
```
|
|
304
|
+
src/
|
|
305
|
+
├── core/
|
|
306
|
+
│ ├── tracker.ts 主类(init + 公共 getter)
|
|
307
|
+
│ ├── track-api.ts track 家族方法
|
|
308
|
+
│ ├── identity-api.ts identify / login / logout / alias
|
|
309
|
+
│ ├── profile-api.ts profile set/append/...
|
|
310
|
+
│ ├── register-api.ts register / registerOnce
|
|
311
|
+
│ ├── send-pipeline.ts sendEvent + flush + waitUntilIdle
|
|
312
|
+
│ ├── identity.ts IdentityManager
|
|
313
|
+
│ ├── uuid.ts UUID v4
|
|
314
|
+
│ ├── logger.ts 日志
|
|
315
|
+
│ ├── env.ts 浏览器能力
|
|
316
|
+
│ ├── stage.ts StageChain(3 阶段)
|
|
317
|
+
│ ├── lifecycle.ts 7 生命周期钩子
|
|
318
|
+
│ ├── plugin.ts PluginRegistry
|
|
319
|
+
│ ├── instance-channel.ts 跨实例通信
|
|
320
|
+
│ ├── retry-policy.ts 指数退避
|
|
321
|
+
│ ├── failed-queue.ts 失败队列
|
|
322
|
+
│ ├── profile-store.ts 用户档案(v1.2)
|
|
323
|
+
│ ├── signed-identity.ts HMAC 签名
|
|
324
|
+
│ ├── errors.ts SDKError 体系
|
|
325
|
+
│ └── instance-factory.ts initPara / registerAppid
|
|
326
|
+
├── storage/ KVStore + IndexedDB
|
|
327
|
+
├── send/ ImageSender / BeaconSender / AjaxSender / BatchSender
|
|
328
|
+
├── presets/ 预置属性
|
|
329
|
+
├── plugins/ 13 个插件
|
|
330
|
+
└── types/ TrackerConfig(10 个子集)+ EventData + 常量
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## 路线图
|
|
334
|
+
|
|
335
|
+
- v1.0:基础 6 插件 ✅
|
|
336
|
+
- v1.1:完整神策 API + 3 阶段 + 7 钩子 + 多 sink + 加密 + 重试 + 跨实例 ✅
|
|
337
|
+
- v1.1.1:架构重构 + 类型分层 + 错误体系 + signedIdentity + 9 个测试套件 ✅
|
|
338
|
+
- v1.2:plugin marketplace / web worker / WebAssembly 加密
|
|
339
|
+
- v2.0:service worker / Source Map / 多端协议统一
|
|
340
|
+
|
|
341
|
+
## 许可
|
|
342
|
+
|
|
343
|
+
MIT
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* event-tracker suggest — AI Event Suggestion (v1.3.0)
|
|
4
|
+
*
|
|
5
|
+
* 扫描项目源码中的 sdk.track 调用,分析:
|
|
6
|
+
* - 事件名(按出现频次排序)
|
|
7
|
+
* - 缺失的预置 property
|
|
8
|
+
* - 命名规范违例(小写 / 中文 / emoji)
|
|
9
|
+
* - 循环里调用警告
|
|
10
|
+
*
|
|
11
|
+
* 输出建议到 console + 可选 JSON
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
15
|
+
import { resolve, extname, basename } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const TRACK_RE = /\bsdk\.track(?:Signup|Linkage)?\s*\(\s*['"`]([^'"`]+)['"`]/g;
|
|
18
|
+
const LOOP_RE = /\b(for|while)\s*\([^)]*\)\s*\{[^}]*sdk\.track/s;
|
|
19
|
+
|
|
20
|
+
async function* walk(dir: string): AsyncGenerator<string> {
|
|
21
|
+
let entries;
|
|
22
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
23
|
+
for (const e of entries) {
|
|
24
|
+
const p = resolve(dir, e.name);
|
|
25
|
+
if (e.isDirectory()) {
|
|
26
|
+
if (['node_modules', 'dist', 'coverage', '.git', 'build'].includes(e.name)) continue;
|
|
27
|
+
yield* walk(p);
|
|
28
|
+
} else {
|
|
29
|
+
const ext = extname(p);
|
|
30
|
+
if (['.ts', '.tsx', '.js', '.jsx', '.vue'].includes(ext)) yield p;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const RULES = [
|
|
36
|
+
{ name: 'should_be_english', test: (n: string) => !/^[\x20-\x7E]+$/.test(n), msg: '事件名建议纯 ASCII(兼容服务端解析)' },
|
|
37
|
+
{ name: 'lowercase_only', test: (n: string) => /^[a-z][a-z0-9_]*$/.test(n), msg: '建议小写 + 下划线(snake_case)' },
|
|
38
|
+
{ name: 'should_be_short', test: (n: string) => n.length <= 32, msg: '事件名建议 ≤ 32 字符' },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
async function analyze(target: string): Promise<{
|
|
42
|
+
events: Record<string, number>;
|
|
43
|
+
loops: Array<{ file: string; line: number }>;
|
|
44
|
+
violations: Array<{ event: string; rule: string; msg: string }>;
|
|
45
|
+
suggestions: string[];
|
|
46
|
+
}> {
|
|
47
|
+
const events: Record<string, number> = {};
|
|
48
|
+
const loops: Array<{ file: string; line: number }> = [];
|
|
49
|
+
const violations: Array<{ event: string; rule: string; msg: string }> = [];
|
|
50
|
+
|
|
51
|
+
for await (const file of walk(resolve(process.cwd(), target))) {
|
|
52
|
+
let content: string;
|
|
53
|
+
try { content = await readFile(file, 'utf8'); } catch { continue; }
|
|
54
|
+
const lines = content.split('\n');
|
|
55
|
+
let m: RegExpExecArray | null;
|
|
56
|
+
TRACK_RE.lastIndex = 0;
|
|
57
|
+
while ((m = TRACK_RE.exec(content)) !== null) {
|
|
58
|
+
const name = m[1];
|
|
59
|
+
events[name] = (events[name] || 0) + 1;
|
|
60
|
+
for (const r of RULES) {
|
|
61
|
+
if (!r.test(name)) violations.push({ event: name, rule: r.name, msg: r.msg });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (let i = 0; i < lines.length; i++) {
|
|
65
|
+
if (LOOP_RE.test(lines[i])) {
|
|
66
|
+
loops.push({ file, line: i + 1 });
|
|
67
|
+
}
|
|
68
|
+
LOOP_RE.lastIndex = 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 建议
|
|
73
|
+
const suggestions: string[] = [];
|
|
74
|
+
const sorted = Object.entries(events).sort((a, b) => b[1] - a[1]);
|
|
75
|
+
for (const [name, count] of sorted) {
|
|
76
|
+
if (count > 10) suggestions.push(`考虑把高频事件 '${name}' 加为预置事件(count=${count})`);
|
|
77
|
+
}
|
|
78
|
+
if (loops.length) suggestions.push(`检测到 ${loops.length} 处循环内 sdk.track 调用,建议改用 batch send 或节流`);
|
|
79
|
+
|
|
80
|
+
return { events, loops, violations, suggestions };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const target = process.argv[2] || 'src';
|
|
84
|
+
const jsonOut = process.argv.includes('--json');
|
|
85
|
+
|
|
86
|
+
// 跳过命令本身
|
|
87
|
+
analyze(target).then((res) => {
|
|
88
|
+
if (jsonOut) {
|
|
89
|
+
// eslint-disable-next-line no-console
|
|
90
|
+
console.log(JSON.stringify(res, null, 2));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// eslint-disable-next-line no-console
|
|
94
|
+
console.log(`\n📊 Event Suggestion Report (target: ${target})\n`);
|
|
95
|
+
// eslint-disable-next-line no-console
|
|
96
|
+
console.log('事件统计:');
|
|
97
|
+
for (const [name, count] of Object.entries(res.events).sort((a, b) => b[1] - a[1])) {
|
|
98
|
+
// eslint-disable-next-line no-console
|
|
99
|
+
console.log(` ${String(count).padStart(4)} ${name}`);
|
|
100
|
+
}
|
|
101
|
+
if (res.violations.length) {
|
|
102
|
+
// eslint-disable-next-line no-console
|
|
103
|
+
console.log(`\n❌ 命名违例(${res.violations.length}):`);
|
|
104
|
+
const grouped: Record<string, Array<{ event: string; msg: string }>> = {};
|
|
105
|
+
for (const v of res.violations) {
|
|
106
|
+
(grouped[v.rule] ||= []).push({ event: v.event, msg: v.msg });
|
|
107
|
+
}
|
|
108
|
+
for (const [rule, list] of Object.entries(grouped)) {
|
|
109
|
+
// eslint-disable-next-line no-console
|
|
110
|
+
console.log(` [${rule}] ${list.length} 次`);
|
|
111
|
+
for (const it of list.slice(0, 3)) {
|
|
112
|
+
// eslint-disable-next-line no-console
|
|
113
|
+
console.log(` - ${it.event}: ${it.msg}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (res.loops.length) {
|
|
118
|
+
// eslint-disable-next-line no-console
|
|
119
|
+
console.log(`\n⚠️ 循环内调用(${res.loops.length}):`);
|
|
120
|
+
for (const l of res.loops.slice(0, 5)) {
|
|
121
|
+
// eslint-disable-next-line no-console
|
|
122
|
+
console.log(` ${l.file}:${l.line}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (res.suggestions.length) {
|
|
126
|
+
// eslint-disable-next-line no-console
|
|
127
|
+
console.log(`\n💡 建议:`);
|
|
128
|
+
for (const s of res.suggestions) {
|
|
129
|
+
// eslint-disable-next-line no-console
|
|
130
|
+
console.log(` - ${s}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// eslint-disable-next-line no-console
|
|
134
|
+
console.log('');
|
|
135
|
+
}).catch((e) => {
|
|
136
|
+
// eslint-disable-next-line no-console
|
|
137
|
+
console.error('suggest failed:', e);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
});
|
|
140
|
+
// keep basename used to avoid unused var warning
|
|
141
|
+
void basename;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* event-tracker CLI (v1.2.0)
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx event-tracker init 生成初始化代码片段
|
|
7
|
+
* npx event-tracker dev 启动本地 mock server
|
|
8
|
+
* npx event-tracker analyze 分析 SDK 产物大小(须先 build)
|
|
9
|
+
*
|
|
10
|
+
* 不强制依赖:纯 ESM CLI,单文件 0 依赖
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import { resolve } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const HELP = `event-tracker CLI (v1.3.0)
|
|
18
|
+
|
|
19
|
+
Commands:
|
|
20
|
+
init [target] 生成初始化代码片段(vue/react/html/node)
|
|
21
|
+
dev 启动 mock 上报服务(端口 8888)
|
|
22
|
+
analyze 分析 dist/ 下产物大小(含 visualizer)
|
|
23
|
+
suggest [dir] AI 事件埋点建议(基于源码扫描)
|
|
24
|
+
help 帮助
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
function printInit(target: string): void {
|
|
28
|
+
const T = (target || 'html').toLowerCase();
|
|
29
|
+
const snippets: Record<string, string> = {
|
|
30
|
+
html: `<script src="https://unpkg.com/@macroui/event-tracker@latest/dist/event-tracker.min.js"></script>
|
|
31
|
+
<script>
|
|
32
|
+
eventTracker.init({
|
|
33
|
+
app_id: 'demo',
|
|
34
|
+
server_url: 'https://api.example.com/sa',
|
|
35
|
+
send_type: 'beacon',
|
|
36
|
+
});
|
|
37
|
+
eventTracker.track('PageView', { $referrer: document.referrer });
|
|
38
|
+
</script>`,
|
|
39
|
+
vue: `import eventTracker from '@macroui/event-tracker';
|
|
40
|
+
eventTracker.init({
|
|
41
|
+
app_id: import.meta.env.VITE_APP_ID,
|
|
42
|
+
server_url: import.meta.env.VITE_SA_URL,
|
|
43
|
+
send_type: 'beacon',
|
|
44
|
+
});
|
|
45
|
+
// 在 main.ts 调用即可`,
|
|
46
|
+
react: `import eventTracker from '@macroui/event-tracker';
|
|
47
|
+
eventTracker.init({
|
|
48
|
+
app_id: process.env.REACT_APP_SA_APP_ID!,
|
|
49
|
+
server_url: process.env.REACT_APP_SA_URL!,
|
|
50
|
+
send_type: 'beacon',
|
|
51
|
+
});
|
|
52
|
+
// 在 index.tsx 顶层调用`,
|
|
53
|
+
node: `import { initPara } from '@macroui/event-tracker';
|
|
54
|
+
const sdk = initPara({
|
|
55
|
+
app_id: 'node-app',
|
|
56
|
+
server_url: 'https://api.example.com/sa',
|
|
57
|
+
send_type: 'ajax',
|
|
58
|
+
});
|
|
59
|
+
sdk.track('ServerStart', { $pid: process.pid });`,
|
|
60
|
+
};
|
|
61
|
+
// eslint-disable-next-line no-console
|
|
62
|
+
console.log(snippets[T] || snippets.html);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function startDev(): Promise<void> {
|
|
66
|
+
const { createServer } = await import('node:http');
|
|
67
|
+
const port = Number(process.env.PORT || 8888);
|
|
68
|
+
const srv = createServer((req, res) => {
|
|
69
|
+
let body = '';
|
|
70
|
+
req.on('data', (c) => { body += c; });
|
|
71
|
+
req.on('end', () => {
|
|
72
|
+
// eslint-disable-next-line no-console
|
|
73
|
+
console.log(`[mock] ${req.method} ${req.url} body=${body.slice(0, 200)}`);
|
|
74
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
75
|
+
res.end(JSON.stringify({ ok: true, received: body.length }));
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
srv.listen(port, () => {
|
|
79
|
+
// eslint-disable-next-line no-console
|
|
80
|
+
console.log(`[event-tracker mock] listening on http://localhost:${port}`);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function analyze(): Promise<void> {
|
|
85
|
+
const distDir = resolve(process.cwd(), 'dist');
|
|
86
|
+
if (!existsSync(distDir)) {
|
|
87
|
+
// eslint-disable-next-line no-console
|
|
88
|
+
console.error('dist/ not found, run `npm run build` first.');
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
const { readdir, stat } = await import('node:fs/promises');
|
|
92
|
+
const files = await readdir(distDir);
|
|
93
|
+
const rows: Array<{ name: string; size: number; gzip: number }> = [];
|
|
94
|
+
for (const f of files) {
|
|
95
|
+
const p = resolve(distDir, f);
|
|
96
|
+
let s;
|
|
97
|
+
try { s = await stat(p); } catch { continue; }
|
|
98
|
+
if (!s.isFile()) continue;
|
|
99
|
+
if (f.endsWith('.map')) continue;
|
|
100
|
+
let gzip = 0;
|
|
101
|
+
if (f.endsWith('.js') || f.endsWith('.cjs') || f.endsWith('.mjs')) {
|
|
102
|
+
try {
|
|
103
|
+
const buf = await readFile(p);
|
|
104
|
+
const { gzipSync } = await import('node:zlib');
|
|
105
|
+
gzip = gzipSync(buf).length;
|
|
106
|
+
} catch { /* ignore */ }
|
|
107
|
+
}
|
|
108
|
+
rows.push({ name: f, size: s.size, gzip });
|
|
109
|
+
}
|
|
110
|
+
rows.sort((a, b) => b.size - a.size);
|
|
111
|
+
// eslint-disable-next-line no-console
|
|
112
|
+
console.log('File Size Gzip');
|
|
113
|
+
for (const r of rows) {
|
|
114
|
+
// eslint-disable-next-line no-console
|
|
115
|
+
console.log(
|
|
116
|
+
`${r.name.padEnd(40)}${(r.size / 1024).toFixed(2).padStart(8)}KB ${(r.gzip / 1024).toFixed(2).padStart(8)}KB`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const vizPath = resolve(distDir, 'stats.html');
|
|
120
|
+
// eslint-disable-next-line no-console
|
|
121
|
+
console.log(`\n可视化报告:${existsSync(vizPath) ? '✅ ' + vizPath : '❌ 未生成(运行 ANALYZE=1 npm run visualize 重新 build)'}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const [, , cmd, arg] = process.argv;
|
|
125
|
+
switch (cmd) {
|
|
126
|
+
case 'init':
|
|
127
|
+
printInit(arg);
|
|
128
|
+
break;
|
|
129
|
+
case 'dev':
|
|
130
|
+
void startDev();
|
|
131
|
+
break;
|
|
132
|
+
case 'analyze':
|
|
133
|
+
void analyze();
|
|
134
|
+
break;
|
|
135
|
+
case 'suggest': {
|
|
136
|
+
// delegate to event-tracker-suggest.mjs
|
|
137
|
+
const { spawn } = await import('node:child_process');
|
|
138
|
+
const target = arg || 'src';
|
|
139
|
+
const isJson = process.argv.includes('--json');
|
|
140
|
+
const child = spawn(process.execPath, [
|
|
141
|
+
new URL('./event-tracker-suggest.mjs', import.meta.url).pathname,
|
|
142
|
+
target,
|
|
143
|
+
...(isJson ? ['--json'] : []),
|
|
144
|
+
], { stdio: 'inherit' });
|
|
145
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case 'help':
|
|
149
|
+
case undefined:
|
|
150
|
+
// eslint-disable-next-line no-console
|
|
151
|
+
console.log(HELP);
|
|
152
|
+
break;
|
|
153
|
+
default:
|
|
154
|
+
// eslint-disable-next-line no-console
|
|
155
|
+
console.error(`Unknown command: ${cmd}\n${HELP}`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|