rsclick-log-sdk-web 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
  - 自动维护 30 分钟 `session_id`
9
9
  - 调用后端 `POST /track/collect` 上报事件
10
10
  - 默认初始化后自动发送 `$page_view`
11
+ - 可选开启全局错误捕获并自动发送 `$error`
11
12
  - 支持 SPA 场景下的路由切换自动发送 `$page_view`
12
13
  - 登录态识别仅影响后续事件
13
14
 
@@ -59,6 +60,7 @@ await init({
59
60
  - 初始化完成后自动发送当前页面的 `$page_view`
60
61
  - 在 SPA 中监听 `history.pushState`、`history.replaceState`、`popstate`、`hashchange`
61
62
  - 同一 URL 不会重复自动上报
63
+ - 默认不会自动捕获全局错误,如需开启请显式设置 `autoErrorCapture: true`
62
64
  ```
63
65
 
64
66
  关闭默认 `$page_view`:
@@ -73,6 +75,26 @@ await init({
73
75
  });
74
76
  ```
75
77
 
78
+ 开启全局错误自动采集:
79
+
80
+ ```ts
81
+ import { init } from "rsclick-log-sdk-web";
82
+
83
+ await init({
84
+ writeKey: "trk_live_xxx",
85
+ endpoint: "https://your-api.example.com/track/collect",
86
+ autoErrorCapture: true,
87
+ });
88
+ ```
89
+
90
+ 开启后 SDK 会在浏览器层监听:
91
+
92
+ - `window.error`
93
+ - `window.unhandledrejection`
94
+
95
+ 这套机制对 `Vue`、`React`、`SolidJS`、`Next.js` 都可用,因为依赖的是浏览器全局事件而不是框架私有 API。
96
+ 如果你在框架内还使用了 `ErrorBoundary`、`onErrorCaptured` 等机制,仍然可以继续手动调用 `track("$error")` 补充业务上下文。
97
+
76
98
  ## 事件示例
77
99
 
78
100
  ```ts
@@ -86,6 +108,24 @@ await track("click_signup_button", {
86
108
  });
87
109
  ```
88
110
 
111
+ 手动捕获错误并补充框架上下文:
112
+
113
+ ```ts
114
+ import { captureError } from "rsclick-log-sdk-web";
115
+
116
+ try {
117
+ throw new Error("checkout failed");
118
+ } catch (error) {
119
+ await captureError(error, {
120
+ source: "manual_try_catch",
121
+ properties: {
122
+ component: "CheckoutPage",
123
+ action: "submit_order",
124
+ },
125
+ });
126
+ }
127
+ ```
128
+
89
129
  主动发送页面浏览事件:
90
130
 
91
131
  ```ts
@@ -153,6 +193,100 @@ SDK 默认上报以下字段:
153
193
  1. 统一使用 `fetch(..., { mode: "cors", credentials: "omit", keepalive: true })`
154
194
  2. SDK 不再依赖 `navigator.sendBeacon`
155
195
 
196
+ 当开启 `autoErrorCapture` 时,`$error` 事件默认会带上以下属性中的可用字段:
197
+
198
+ - `error_kind`:`window_error` 或 `unhandledrejection`
199
+ - `message`
200
+ - `error_name`
201
+ - `filename`
202
+ - `lineno`
203
+ - `colno`
204
+ - `stack`
205
+
206
+ 手动调用 `captureError()` 时,还会额外带上:
207
+
208
+ - `error_kind`:固定为 `captured_error`
209
+ - `source`:手动传入的来源标识,默认值为 `manual`
210
+
211
+ ## 框架接入示例
212
+
213
+ React Error Boundary:
214
+
215
+ ```tsx
216
+ import { captureError } from "rsclick-log-sdk-web";
217
+
218
+ export const AppErrorFallback = ({ error }: { error: Error }) => {
219
+ void captureError(error, {
220
+ source: "react_error_boundary",
221
+ properties: {
222
+ component: "AppErrorFallback",
223
+ },
224
+ });
225
+
226
+ return <div>Something went wrong.</div>;
227
+ };
228
+ ```
229
+
230
+ Vue 3 `onErrorCaptured`:
231
+
232
+ ```ts
233
+ import { onErrorCaptured } from "vue";
234
+ import { captureError } from "rsclick-log-sdk-web";
235
+
236
+ onErrorCaptured((error, instance, info) => {
237
+ void captureError(error, {
238
+ source: "vue_on_error_captured",
239
+ properties: {
240
+ component: instance?.type?.name ?? null,
241
+ info,
242
+ },
243
+ });
244
+
245
+ return false;
246
+ });
247
+ ```
248
+
249
+ SolidJS `ErrorBoundary`:
250
+
251
+ ```tsx
252
+ import { ErrorBoundary } from "solid-js";
253
+ import { captureError } from "rsclick-log-sdk-web";
254
+
255
+ <ErrorBoundary
256
+ fallback={(error) => {
257
+ void captureError(error, {
258
+ source: "solid_error_boundary",
259
+ });
260
+
261
+ return <div>Something went wrong.</div>;
262
+ }}
263
+ >
264
+ <App />
265
+ </ErrorBoundary>;
266
+ ```
267
+
268
+ Next.js `app/error.tsx`:
269
+
270
+ ```tsx
271
+ "use client";
272
+
273
+ import { useEffect } from "react";
274
+ import { captureError } from "rsclick-log-sdk-web";
275
+
276
+ export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
277
+ useEffect(() => {
278
+ void captureError(error, {
279
+ source: "next_app_error",
280
+ properties: {
281
+ digest: error.digest ?? null,
282
+ },
283
+ });
284
+ }, [error]);
285
+
286
+ return <html><body>Something went wrong.</body></html>;
287
+ }
288
+ ```
289
+
156
290
  ## 发布说明
157
291
 
158
292
  当前 npm 包名为 `rsclick-log-sdk-web`。如果要发布到 npm,按下面步骤操作:
package/dist/browser.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  declare const TrackingAnalyticsSDK: {
2
+ captureError: (error: unknown, options?: import("./types").CaptureErrorOptions) => Promise<import("./types").TrackingDispatchResult>;
2
3
  init: (options: import("./types").TrackingInitOptions) => Promise<void>;
3
4
  track: (eventName: string, options?: import("./types").TrackOptions) => Promise<import("./types").TrackingDispatchResult>;
4
5
  identify: (userId: string | null) => void;
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
- export { getDebugState, identify, init, page, reset, track } from "./sdk";
2
- export type { PageOptions, TrackOptions, TrackingCollectPayload, TrackingDispatchResult, TrackingEventPayload, TrackingInitOptions, TrackingProperties, TrackingResponse, TrackingScalar, TrackingStateSnapshot, } from "./types";
1
+ export { PREDEFINED_TRACKING_EVENTS, type PredefinedTrackingEventName, } from "./predefined-events";
2
+ export { captureError, getDebugState, identify, init, page, reset, track } from "./sdk";
3
+ export type { CaptureErrorOptions, PageOptions, TrackOptions, TrackingCollectPayload, TrackingDispatchResult, TrackingEventPayload, TrackingInitOptions, TrackingProperties, TrackingResponse, TrackingScalar, TrackingStateSnapshot, } from "./types";
@@ -33,6 +33,41 @@
33
33
  TrackingAnalyticsSDK: () => TrackingAnalyticsSDK
34
34
  });
35
35
 
36
+ // src/predefined-events.ts
37
+ var PREDEFINED_TRACKING_EVENTS = {
38
+ pageView: {
39
+ eventName: "$page_view",
40
+ displayName: "页面浏览"
41
+ },
42
+ elementClick: {
43
+ eventName: "$element_click",
44
+ displayName: "元素点击"
45
+ },
46
+ formSubmit: {
47
+ eventName: "$form_submit",
48
+ displayName: "表单提交"
49
+ },
50
+ search: {
51
+ eventName: "$search",
52
+ displayName: "发起搜索"
53
+ },
54
+ exposure: {
55
+ eventName: "$exposure",
56
+ displayName: "内容曝光"
57
+ },
58
+ fileDownload: {
59
+ eventName: "$file_download",
60
+ displayName: "文件下载"
61
+ },
62
+ share: {
63
+ eventName: "$share",
64
+ displayName: "分享"
65
+ },
66
+ error: {
67
+ eventName: "$error",
68
+ displayName: "异常错误"
69
+ }
70
+ };
36
71
  // src/sdk-core.ts
37
72
  var DEFAULT_ENDPOINT = "/track/collect";
38
73
  var DEFAULT_SESSION_TIMEOUT_MINUTES = 30;
@@ -52,6 +87,7 @@
52
87
  writeKey,
53
88
  endpoint: normalizeOptionalString(options.endpoint) ?? DEFAULT_ENDPOINT,
54
89
  autoPageview: options.autoPageview ?? true,
90
+ autoErrorCapture: options.autoErrorCapture ?? false,
55
91
  sessionTimeoutMs: Math.max(options.sessionTimeoutMinutes ?? DEFAULT_SESSION_TIMEOUT_MINUTES, 1) * 60000,
56
92
  storagePrefix: normalizeOptionalString(options.storagePrefix) ?? DEFAULT_STORAGE_PREFIX
57
93
  };
@@ -261,6 +297,157 @@
261
297
  return await response.json();
262
298
  };
263
299
 
300
+ // src/sdk-error.ts
301
+ var MAX_ERROR_MESSAGE_LENGTH = 500;
302
+ var MAX_ERROR_STACK_LENGTH = 2000;
303
+ var installGlobalErrorListeners = (browser, client) => {
304
+ const addEventListener = browser.addEventListener?.bind(globalThis);
305
+ const removeEventListener = browser.removeEventListener?.bind(globalThis);
306
+ if (!addEventListener || !removeEventListener) {
307
+ return null;
308
+ }
309
+ const runtime = {
310
+ lastFingerprint: null,
311
+ lastCapturedAt: 0
312
+ };
313
+ const onError = (event) => {
314
+ captureErrorEvent(client, runtime, toWindowErrorTrackOptions(event));
315
+ };
316
+ const onUnhandledRejection = (event) => {
317
+ captureErrorEvent(client, runtime, toUnhandledRejectionTrackOptions(event));
318
+ };
319
+ addEventListener("error", onError);
320
+ addEventListener("unhandledrejection", onUnhandledRejection);
321
+ return () => {
322
+ removeEventListener("error", onError);
323
+ removeEventListener("unhandledrejection", onUnhandledRejection);
324
+ };
325
+ };
326
+ var captureErrorEvent = async (client, runtime, options) => {
327
+ const fingerprint = buildFingerprint(options.properties);
328
+ const now = Date.now();
329
+ if (fingerprint && runtime.lastFingerprint === fingerprint && now - runtime.lastCapturedAt < 1000) {
330
+ return;
331
+ }
332
+ runtime.lastFingerprint = fingerprint;
333
+ runtime.lastCapturedAt = now;
334
+ try {
335
+ await client.track(PREDEFINED_TRACKING_EVENTS.error.eventName, options);
336
+ } catch {
337
+ return;
338
+ }
339
+ };
340
+ var toWindowErrorTrackOptions = (event) => {
341
+ const record = asRecord(event);
342
+ const nestedError = record.error;
343
+ const error = nestedError instanceof Error ? nestedError : null;
344
+ const message = firstNonEmptyString(asString(record.message), error?.message, "Script error");
345
+ const properties = {
346
+ error_kind: "window_error",
347
+ message: truncate(message, MAX_ERROR_MESSAGE_LENGTH),
348
+ filename: normalizeOptionalString(asString(record.filename)),
349
+ lineno: asNumber(record.lineno),
350
+ colno: asNumber(record.colno),
351
+ stack: truncate(normalizeOptionalString(error?.stack) ?? message, MAX_ERROR_STACK_LENGTH)
352
+ };
353
+ return {
354
+ properties: compactProperties(properties)
355
+ };
356
+ };
357
+ var toUnhandledRejectionTrackOptions = (event) => {
358
+ const record = asRecord(event);
359
+ const reason = record.reason;
360
+ const normalized = normalizeUnknownError(reason);
361
+ const properties = {
362
+ error_kind: "unhandledrejection",
363
+ message: truncate(normalized.message, MAX_ERROR_MESSAGE_LENGTH),
364
+ error_name: normalized.name,
365
+ stack: truncate(normalized.stack ?? normalized.message, MAX_ERROR_STACK_LENGTH)
366
+ };
367
+ return {
368
+ properties: compactProperties(properties)
369
+ };
370
+ };
371
+ var toManualErrorTrackOptions = (error, options = {}) => {
372
+ const normalized = normalizeUnknownError(error);
373
+ const source = normalizeOptionalString(options.source) ?? "manual";
374
+ const properties = {
375
+ error_kind: "captured_error",
376
+ error_name: normalized.name,
377
+ message: truncate(normalized.message, MAX_ERROR_MESSAGE_LENGTH),
378
+ stack: truncate(normalized.stack ?? normalized.message, MAX_ERROR_STACK_LENGTH),
379
+ source,
380
+ ...options.properties
381
+ };
382
+ return {
383
+ eventTime: options.eventTime,
384
+ page: options.page,
385
+ properties: compactProperties(properties)
386
+ };
387
+ };
388
+ var normalizeUnknownError = (input) => {
389
+ if (input instanceof Error) {
390
+ return {
391
+ name: normalizeOptionalString(input.name),
392
+ message: input.message || input.name || "Unknown error",
393
+ stack: normalizeOptionalString(input.stack)
394
+ };
395
+ }
396
+ const record = asRecord(input);
397
+ const name = normalizeOptionalString(asString(record.name));
398
+ const message = firstNonEmptyString(asString(record.message), stringifyUnknown(input), "Unknown error") ?? "Unknown error";
399
+ return {
400
+ name,
401
+ message,
402
+ stack: normalizeOptionalString(asString(record.stack))
403
+ };
404
+ };
405
+ var buildFingerprint = (properties) => {
406
+ if (!properties) {
407
+ return null;
408
+ }
409
+ return JSON.stringify(Object.entries(properties).sort(([left], [right]) => left.localeCompare(right)));
410
+ };
411
+ var compactProperties = (properties) => Object.entries(properties).reduce((accumulator, [key, value]) => {
412
+ if (typeof value === "string") {
413
+ const normalized = normalizeOptionalString(value);
414
+ if (normalized) {
415
+ accumulator[key] = normalized;
416
+ }
417
+ return accumulator;
418
+ }
419
+ if (typeof value === "number" || typeof value === "boolean" || value === null) {
420
+ accumulator[key] = value;
421
+ }
422
+ return accumulator;
423
+ }, {});
424
+ var asRecord = (value) => typeof value === "object" && value !== null ? value : {};
425
+ var asString = (value) => typeof value === "string" ? value : null;
426
+ var asNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
427
+ var firstNonEmptyString = (...values) => {
428
+ for (const value of values) {
429
+ const normalized = normalizeOptionalString(value);
430
+ if (normalized) {
431
+ return normalized;
432
+ }
433
+ }
434
+ return null;
435
+ };
436
+ var truncate = (value, maxLength) => value && value.length > maxLength ? value.slice(0, maxLength) : value;
437
+ var stringifyUnknown = (value) => {
438
+ if (typeof value === "string") {
439
+ return value;
440
+ }
441
+ if (typeof value === "number" || typeof value === "boolean" || value === null) {
442
+ return String(value);
443
+ }
444
+ try {
445
+ return JSON.stringify(value);
446
+ } catch {
447
+ return null;
448
+ }
449
+ };
450
+
264
451
  // src/sdk-client.ts
265
452
  var createTrackingClient = (options) => {
266
453
  const browser = resolveBrowser();
@@ -268,6 +455,7 @@
268
455
  const storage = createScopedStorage(browser, config.storagePrefix);
269
456
  let state = loadState(browser, storage);
270
457
  let routeListenerCleanup = null;
458
+ let errorListenerCleanup = null;
271
459
  const routeRuntime = {
272
460
  timer: null,
273
461
  lastAutoPageUrl: normalizeOptionalString(browser.location?.href)
@@ -307,7 +495,7 @@
307
495
  return null;
308
496
  }
309
497
  routeRuntime.lastAutoPageUrl = currentUrl;
310
- return dispatchCollect(browser, config.endpoint, createCollectPayload("$page_view"));
498
+ return dispatchCollect(browser, config.endpoint, createCollectPayload(PREDEFINED_TRACKING_EVENTS.pageView.eventName));
311
499
  };
312
500
  const installRouteListeners = () => {
313
501
  const addEventListener = browser.addEventListener?.bind(globalThis);
@@ -351,14 +539,19 @@
351
539
  };
352
540
  return {
353
541
  init: async () => {
354
- if (!config.autoPageview) {
355
- return;
542
+ if (config.autoPageview) {
543
+ await dispatchCollect(browser, config.endpoint, createCollectPayload(PREDEFINED_TRACKING_EVENTS.pageView.eventName));
544
+ routeRuntime.lastAutoPageUrl = normalizeOptionalString(browser.location?.href);
545
+ routeListenerCleanup = installRouteListeners();
546
+ }
547
+ if (config.autoErrorCapture) {
548
+ errorListenerCleanup = installGlobalErrorListeners(browser, {
549
+ track: async (eventName, options2 = {}) => dispatchCollect(browser, config.endpoint, createCollectPayload(eventName, options2))
550
+ });
356
551
  }
357
- await dispatchCollect(browser, config.endpoint, createCollectPayload("$page_view"));
358
- routeRuntime.lastAutoPageUrl = normalizeOptionalString(browser.location?.href);
359
- routeListenerCleanup = installRouteListeners();
360
552
  },
361
553
  track: async (eventName, options2 = {}) => dispatchCollect(browser, config.endpoint, createCollectPayload(eventName, options2)),
554
+ captureError: async (error, options2 = {}) => dispatchCollect(browser, config.endpoint, createCollectPayload(PREDEFINED_TRACKING_EVENTS.error.eventName, toManualErrorTrackOptions(error, options2))),
362
555
  identify: (userId) => {
363
556
  state = {
364
557
  ...state,
@@ -366,7 +559,7 @@
366
559
  };
367
560
  persistState(storage, state);
368
561
  },
369
- page: async (options2 = {}) => dispatchCollect(browser, config.endpoint, createCollectPayload("$page_view", {
562
+ page: async (options2 = {}) => dispatchCollect(browser, config.endpoint, createCollectPayload(PREDEFINED_TRACKING_EVENTS.pageView.eventName, {
370
563
  properties: options2.properties,
371
564
  page: options2
372
565
  })),
@@ -380,6 +573,8 @@
380
573
  destroy: () => {
381
574
  routeListenerCleanup?.();
382
575
  routeListenerCleanup = null;
576
+ errorListenerCleanup?.();
577
+ errorListenerCleanup = null;
383
578
  }
384
579
  };
385
580
  };
@@ -392,6 +587,7 @@
392
587
  await trackingClient.init();
393
588
  };
394
589
  var track = async (eventName, options = {}) => getClient().track(eventName, options);
590
+ var captureError = async (error, options = {}) => getClient().captureError(error, options);
395
591
  var identify = (userId) => {
396
592
  getClient().identify(userId);
397
593
  };
@@ -406,6 +602,7 @@
406
602
  };
407
603
  // src/browser.ts
408
604
  var TrackingAnalyticsSDK = {
605
+ captureError,
409
606
  init,
410
607
  track,
411
608
  identify,
package/dist/index.js CHANGED
@@ -1,3 +1,38 @@
1
+ // src/predefined-events.ts
2
+ var PREDEFINED_TRACKING_EVENTS = {
3
+ pageView: {
4
+ eventName: "$page_view",
5
+ displayName: "页面浏览"
6
+ },
7
+ elementClick: {
8
+ eventName: "$element_click",
9
+ displayName: "元素点击"
10
+ },
11
+ formSubmit: {
12
+ eventName: "$form_submit",
13
+ displayName: "表单提交"
14
+ },
15
+ search: {
16
+ eventName: "$search",
17
+ displayName: "发起搜索"
18
+ },
19
+ exposure: {
20
+ eventName: "$exposure",
21
+ displayName: "内容曝光"
22
+ },
23
+ fileDownload: {
24
+ eventName: "$file_download",
25
+ displayName: "文件下载"
26
+ },
27
+ share: {
28
+ eventName: "$share",
29
+ displayName: "分享"
30
+ },
31
+ error: {
32
+ eventName: "$error",
33
+ displayName: "异常错误"
34
+ }
35
+ };
1
36
  // src/sdk-core.ts
2
37
  var DEFAULT_ENDPOINT = "/track/collect";
3
38
  var DEFAULT_SESSION_TIMEOUT_MINUTES = 30;
@@ -17,6 +52,7 @@ var normalizeConfig = (options) => {
17
52
  writeKey,
18
53
  endpoint: normalizeOptionalString(options.endpoint) ?? DEFAULT_ENDPOINT,
19
54
  autoPageview: options.autoPageview ?? true,
55
+ autoErrorCapture: options.autoErrorCapture ?? false,
20
56
  sessionTimeoutMs: Math.max(options.sessionTimeoutMinutes ?? DEFAULT_SESSION_TIMEOUT_MINUTES, 1) * 60000,
21
57
  storagePrefix: normalizeOptionalString(options.storagePrefix) ?? DEFAULT_STORAGE_PREFIX
22
58
  };
@@ -226,6 +262,157 @@ var postWithFetch = async (browser, endpoint, body) => {
226
262
  return await response.json();
227
263
  };
228
264
 
265
+ // src/sdk-error.ts
266
+ var MAX_ERROR_MESSAGE_LENGTH = 500;
267
+ var MAX_ERROR_STACK_LENGTH = 2000;
268
+ var installGlobalErrorListeners = (browser, client) => {
269
+ const addEventListener = browser.addEventListener?.bind(globalThis);
270
+ const removeEventListener = browser.removeEventListener?.bind(globalThis);
271
+ if (!addEventListener || !removeEventListener) {
272
+ return null;
273
+ }
274
+ const runtime = {
275
+ lastFingerprint: null,
276
+ lastCapturedAt: 0
277
+ };
278
+ const onError = (event) => {
279
+ captureErrorEvent(client, runtime, toWindowErrorTrackOptions(event));
280
+ };
281
+ const onUnhandledRejection = (event) => {
282
+ captureErrorEvent(client, runtime, toUnhandledRejectionTrackOptions(event));
283
+ };
284
+ addEventListener("error", onError);
285
+ addEventListener("unhandledrejection", onUnhandledRejection);
286
+ return () => {
287
+ removeEventListener("error", onError);
288
+ removeEventListener("unhandledrejection", onUnhandledRejection);
289
+ };
290
+ };
291
+ var captureErrorEvent = async (client, runtime, options) => {
292
+ const fingerprint = buildFingerprint(options.properties);
293
+ const now = Date.now();
294
+ if (fingerprint && runtime.lastFingerprint === fingerprint && now - runtime.lastCapturedAt < 1000) {
295
+ return;
296
+ }
297
+ runtime.lastFingerprint = fingerprint;
298
+ runtime.lastCapturedAt = now;
299
+ try {
300
+ await client.track(PREDEFINED_TRACKING_EVENTS.error.eventName, options);
301
+ } catch {
302
+ return;
303
+ }
304
+ };
305
+ var toWindowErrorTrackOptions = (event) => {
306
+ const record = asRecord(event);
307
+ const nestedError = record.error;
308
+ const error = nestedError instanceof Error ? nestedError : null;
309
+ const message = firstNonEmptyString(asString(record.message), error?.message, "Script error");
310
+ const properties = {
311
+ error_kind: "window_error",
312
+ message: truncate(message, MAX_ERROR_MESSAGE_LENGTH),
313
+ filename: normalizeOptionalString(asString(record.filename)),
314
+ lineno: asNumber(record.lineno),
315
+ colno: asNumber(record.colno),
316
+ stack: truncate(normalizeOptionalString(error?.stack) ?? message, MAX_ERROR_STACK_LENGTH)
317
+ };
318
+ return {
319
+ properties: compactProperties(properties)
320
+ };
321
+ };
322
+ var toUnhandledRejectionTrackOptions = (event) => {
323
+ const record = asRecord(event);
324
+ const reason = record.reason;
325
+ const normalized = normalizeUnknownError(reason);
326
+ const properties = {
327
+ error_kind: "unhandledrejection",
328
+ message: truncate(normalized.message, MAX_ERROR_MESSAGE_LENGTH),
329
+ error_name: normalized.name,
330
+ stack: truncate(normalized.stack ?? normalized.message, MAX_ERROR_STACK_LENGTH)
331
+ };
332
+ return {
333
+ properties: compactProperties(properties)
334
+ };
335
+ };
336
+ var toManualErrorTrackOptions = (error, options = {}) => {
337
+ const normalized = normalizeUnknownError(error);
338
+ const source = normalizeOptionalString(options.source) ?? "manual";
339
+ const properties = {
340
+ error_kind: "captured_error",
341
+ error_name: normalized.name,
342
+ message: truncate(normalized.message, MAX_ERROR_MESSAGE_LENGTH),
343
+ stack: truncate(normalized.stack ?? normalized.message, MAX_ERROR_STACK_LENGTH),
344
+ source,
345
+ ...options.properties
346
+ };
347
+ return {
348
+ eventTime: options.eventTime,
349
+ page: options.page,
350
+ properties: compactProperties(properties)
351
+ };
352
+ };
353
+ var normalizeUnknownError = (input) => {
354
+ if (input instanceof Error) {
355
+ return {
356
+ name: normalizeOptionalString(input.name),
357
+ message: input.message || input.name || "Unknown error",
358
+ stack: normalizeOptionalString(input.stack)
359
+ };
360
+ }
361
+ const record = asRecord(input);
362
+ const name = normalizeOptionalString(asString(record.name));
363
+ const message = firstNonEmptyString(asString(record.message), stringifyUnknown(input), "Unknown error") ?? "Unknown error";
364
+ return {
365
+ name,
366
+ message,
367
+ stack: normalizeOptionalString(asString(record.stack))
368
+ };
369
+ };
370
+ var buildFingerprint = (properties) => {
371
+ if (!properties) {
372
+ return null;
373
+ }
374
+ return JSON.stringify(Object.entries(properties).sort(([left], [right]) => left.localeCompare(right)));
375
+ };
376
+ var compactProperties = (properties) => Object.entries(properties).reduce((accumulator, [key, value]) => {
377
+ if (typeof value === "string") {
378
+ const normalized = normalizeOptionalString(value);
379
+ if (normalized) {
380
+ accumulator[key] = normalized;
381
+ }
382
+ return accumulator;
383
+ }
384
+ if (typeof value === "number" || typeof value === "boolean" || value === null) {
385
+ accumulator[key] = value;
386
+ }
387
+ return accumulator;
388
+ }, {});
389
+ var asRecord = (value) => typeof value === "object" && value !== null ? value : {};
390
+ var asString = (value) => typeof value === "string" ? value : null;
391
+ var asNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
392
+ var firstNonEmptyString = (...values) => {
393
+ for (const value of values) {
394
+ const normalized = normalizeOptionalString(value);
395
+ if (normalized) {
396
+ return normalized;
397
+ }
398
+ }
399
+ return null;
400
+ };
401
+ var truncate = (value, maxLength) => value && value.length > maxLength ? value.slice(0, maxLength) : value;
402
+ var stringifyUnknown = (value) => {
403
+ if (typeof value === "string") {
404
+ return value;
405
+ }
406
+ if (typeof value === "number" || typeof value === "boolean" || value === null) {
407
+ return String(value);
408
+ }
409
+ try {
410
+ return JSON.stringify(value);
411
+ } catch {
412
+ return null;
413
+ }
414
+ };
415
+
229
416
  // src/sdk-client.ts
230
417
  var createTrackingClient = (options) => {
231
418
  const browser = resolveBrowser();
@@ -233,6 +420,7 @@ var createTrackingClient = (options) => {
233
420
  const storage = createScopedStorage(browser, config.storagePrefix);
234
421
  let state = loadState(browser, storage);
235
422
  let routeListenerCleanup = null;
423
+ let errorListenerCleanup = null;
236
424
  const routeRuntime = {
237
425
  timer: null,
238
426
  lastAutoPageUrl: normalizeOptionalString(browser.location?.href)
@@ -272,7 +460,7 @@ var createTrackingClient = (options) => {
272
460
  return null;
273
461
  }
274
462
  routeRuntime.lastAutoPageUrl = currentUrl;
275
- return dispatchCollect(browser, config.endpoint, createCollectPayload("$page_view"));
463
+ return dispatchCollect(browser, config.endpoint, createCollectPayload(PREDEFINED_TRACKING_EVENTS.pageView.eventName));
276
464
  };
277
465
  const installRouteListeners = () => {
278
466
  const addEventListener = browser.addEventListener?.bind(globalThis);
@@ -316,14 +504,19 @@ var createTrackingClient = (options) => {
316
504
  };
317
505
  return {
318
506
  init: async () => {
319
- if (!config.autoPageview) {
320
- return;
507
+ if (config.autoPageview) {
508
+ await dispatchCollect(browser, config.endpoint, createCollectPayload(PREDEFINED_TRACKING_EVENTS.pageView.eventName));
509
+ routeRuntime.lastAutoPageUrl = normalizeOptionalString(browser.location?.href);
510
+ routeListenerCleanup = installRouteListeners();
511
+ }
512
+ if (config.autoErrorCapture) {
513
+ errorListenerCleanup = installGlobalErrorListeners(browser, {
514
+ track: async (eventName, options2 = {}) => dispatchCollect(browser, config.endpoint, createCollectPayload(eventName, options2))
515
+ });
321
516
  }
322
- await dispatchCollect(browser, config.endpoint, createCollectPayload("$page_view"));
323
- routeRuntime.lastAutoPageUrl = normalizeOptionalString(browser.location?.href);
324
- routeListenerCleanup = installRouteListeners();
325
517
  },
326
518
  track: async (eventName, options2 = {}) => dispatchCollect(browser, config.endpoint, createCollectPayload(eventName, options2)),
519
+ captureError: async (error, options2 = {}) => dispatchCollect(browser, config.endpoint, createCollectPayload(PREDEFINED_TRACKING_EVENTS.error.eventName, toManualErrorTrackOptions(error, options2))),
327
520
  identify: (userId) => {
328
521
  state = {
329
522
  ...state,
@@ -331,7 +524,7 @@ var createTrackingClient = (options) => {
331
524
  };
332
525
  persistState(storage, state);
333
526
  },
334
- page: async (options2 = {}) => dispatchCollect(browser, config.endpoint, createCollectPayload("$page_view", {
527
+ page: async (options2 = {}) => dispatchCollect(browser, config.endpoint, createCollectPayload(PREDEFINED_TRACKING_EVENTS.pageView.eventName, {
335
528
  properties: options2.properties,
336
529
  page: options2
337
530
  })),
@@ -345,6 +538,8 @@ var createTrackingClient = (options) => {
345
538
  destroy: () => {
346
539
  routeListenerCleanup?.();
347
540
  routeListenerCleanup = null;
541
+ errorListenerCleanup?.();
542
+ errorListenerCleanup = null;
348
543
  }
349
544
  };
350
545
  };
@@ -357,6 +552,7 @@ var init = async (options) => {
357
552
  await trackingClient.init();
358
553
  };
359
554
  var track = async (eventName, options = {}) => getClient().track(eventName, options);
555
+ var captureError = async (error, options = {}) => getClient().captureError(error, options);
360
556
  var identify = (userId) => {
361
557
  getClient().identify(userId);
362
558
  };
@@ -375,5 +571,7 @@ export {
375
571
  page,
376
572
  init,
377
573
  identify,
378
- getDebugState
574
+ getDebugState,
575
+ captureError,
576
+ PREDEFINED_TRACKING_EVENTS
379
577
  };
@@ -0,0 +1,35 @@
1
+ export declare const PREDEFINED_TRACKING_EVENTS: {
2
+ readonly pageView: {
3
+ readonly eventName: "$page_view";
4
+ readonly displayName: "页面浏览";
5
+ };
6
+ readonly elementClick: {
7
+ readonly eventName: "$element_click";
8
+ readonly displayName: "元素点击";
9
+ };
10
+ readonly formSubmit: {
11
+ readonly eventName: "$form_submit";
12
+ readonly displayName: "表单提交";
13
+ };
14
+ readonly search: {
15
+ readonly eventName: "$search";
16
+ readonly displayName: "发起搜索";
17
+ };
18
+ readonly exposure: {
19
+ readonly eventName: "$exposure";
20
+ readonly displayName: "内容曝光";
21
+ };
22
+ readonly fileDownload: {
23
+ readonly eventName: "$file_download";
24
+ readonly displayName: "文件下载";
25
+ };
26
+ readonly share: {
27
+ readonly eventName: "$share";
28
+ readonly displayName: "分享";
29
+ };
30
+ readonly error: {
31
+ readonly eventName: "$error";
32
+ readonly displayName: "异常错误";
33
+ };
34
+ };
35
+ export type PredefinedTrackingEventName = (typeof PREDEFINED_TRACKING_EVENTS)[keyof typeof PREDEFINED_TRACKING_EVENTS]["eventName"];
@@ -1,4 +1,4 @@
1
- import type { PageOptions, TrackOptions, TrackingDispatchResult, TrackingInitOptions, TrackingStateSnapshot } from "./types";
1
+ import type { CaptureErrorOptions, PageOptions, TrackOptions, TrackingDispatchResult, TrackingInitOptions, TrackingStateSnapshot } from "./types";
2
2
  export declare const DEFAULT_ENDPOINT = "/track/collect";
3
3
  export declare const DEFAULT_SESSION_TIMEOUT_MINUTES = 30;
4
4
  export declare const DEFAULT_STORAGE_PREFIX = "rs_tracking_sdk";
@@ -52,12 +52,14 @@ export interface NormalizedConfig {
52
52
  writeKey: string;
53
53
  endpoint: string;
54
54
  autoPageview: boolean;
55
+ autoErrorCapture: boolean;
55
56
  sessionTimeoutMs: number;
56
57
  storagePrefix: string;
57
58
  }
58
59
  export interface TrackingClient {
59
60
  init: () => Promise<void>;
60
61
  track: (eventName: string, options?: TrackOptions) => Promise<TrackingDispatchResult>;
62
+ captureError: (error: unknown, options?: CaptureErrorOptions) => Promise<TrackingDispatchResult>;
61
63
  identify: (userId: string | null) => void;
62
64
  page: (options?: PageOptions) => Promise<TrackingDispatchResult>;
63
65
  reset: () => TrackingStateSnapshot;
@@ -0,0 +1,18 @@
1
+ import type { CaptureErrorOptions, TrackOptions } from "./types";
2
+ import { type BrowserLike, type TrackingClient } from "./sdk-core";
3
+ /**
4
+ * 安装全局错误监听,将浏览器未处理异常统一转成 `$error` 事件。
5
+ *
6
+ * @param browser - 浏览器能力对象
7
+ * @param client - 当前追踪客户端
8
+ * @returns 返回卸载监听的方法;环境能力不足时返回 null
9
+ */
10
+ export declare const installGlobalErrorListeners: (browser: BrowserLike, client: Pick<TrackingClient, "track">) => (() => void) | null;
11
+ /**
12
+ * 将任意错误对象转换为 `$error` 事件参数,供框架级手动上报复用。
13
+ *
14
+ * @param error - 任意错误对象
15
+ * @param options - 额外上下文与自定义属性
16
+ * @returns 返回 SDK 可直接上报的 track 参数
17
+ */
18
+ export declare const toManualErrorTrackOptions: (error: unknown, options?: CaptureErrorOptions) => TrackOptions;
package/dist/sdk.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PageOptions, TrackOptions, TrackingDispatchResult, TrackingInitOptions, TrackingStateSnapshot } from "./types";
1
+ import type { CaptureErrorOptions, PageOptions, TrackOptions, TrackingDispatchResult, TrackingInitOptions, TrackingStateSnapshot } from "./types";
2
2
  /**
3
3
  * 初始化 SDK,并按默认配置发送一次 `$page_view`。
4
4
  *
@@ -14,6 +14,14 @@ export declare const init: (options: TrackingInitOptions) => Promise<void>;
14
14
  * @returns 返回本次上报的传输结果
15
15
  */
16
16
  export declare const track: (eventName: string, options?: TrackOptions) => Promise<TrackingDispatchResult>;
17
+ /**
18
+ * 手动捕获错误并按 `$error` 事件结构上报。
19
+ *
20
+ * @param error - 任意错误对象
21
+ * @param options - 额外页面上下文与业务属性
22
+ * @returns 返回本次上报的传输结果
23
+ */
24
+ export declare const captureError: (error: unknown, options?: CaptureErrorOptions) => Promise<TrackingDispatchResult>;
17
25
  /**
18
26
  * 识别当前登录用户,仅影响后续事件。
19
27
  *
package/dist/types.d.ts CHANGED
@@ -4,6 +4,7 @@ export interface TrackingInitOptions {
4
4
  writeKey: string;
5
5
  endpoint: string;
6
6
  autoPageview?: boolean;
7
+ autoErrorCapture?: boolean;
7
8
  sessionTimeoutMinutes?: number;
8
9
  storagePrefix?: string;
9
10
  }
@@ -19,6 +20,9 @@ export interface TrackOptions {
19
20
  properties?: TrackingProperties;
20
21
  page?: PageOptions;
21
22
  }
23
+ export interface CaptureErrorOptions extends TrackOptions {
24
+ source?: string;
25
+ }
22
26
  export interface TrackingEventPayload {
23
27
  event_name: string;
24
28
  event_time?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rsclick-log-sdk-web",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Lightweight Web tracking SDK for rs-click-log.",