@telemetry-tracker/core 1.3.0 → 1.5.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.
@@ -0,0 +1,9 @@
1
+ /** Lightweight browser/OS hints for session ingest (no UA parser dependency). */
2
+ export type DeviceContext = {
3
+ device_browser?: string;
4
+ device_os?: string;
5
+ country?: string;
6
+ };
7
+ /** Best-effort device + country from the runtime environment. */
8
+ export declare function readDeviceContext(): DeviceContext;
9
+ //# sourceMappingURL=device-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-context.d.ts","sourceRoot":"","sources":["../src/device-context.ts"],"names":[],"mappings":"AAAA,iFAAiF;AAEjF,MAAM,MAAM,aAAa,GAAG;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC;AAkCF,iEAAiE;AACjE,wBAAgB,iBAAiB,IAAI,aAAa,CASjD"}
@@ -0,0 +1,53 @@
1
+ /** Lightweight browser/OS hints for session ingest (no UA parser dependency). */
2
+ function parseBrowser(ua) {
3
+ if (/Edg\//i.test(ua))
4
+ return "Edge";
5
+ if (/OPR\//i.test(ua) || /Opera/i.test(ua))
6
+ return "Opera";
7
+ if (/Firefox\//i.test(ua))
8
+ return "Firefox";
9
+ if (/Chrome\//i.test(ua) && !/Edg\//i.test(ua))
10
+ return "Chrome";
11
+ if (/Safari\//i.test(ua) && !/Chrome\//i.test(ua))
12
+ return "Safari";
13
+ return undefined;
14
+ }
15
+ function parseOs(ua) {
16
+ if (/Windows NT/i.test(ua))
17
+ return "Windows";
18
+ if (/Mac OS X|Macintosh/i.test(ua))
19
+ return "macOS";
20
+ if (/Android/i.test(ua))
21
+ return "Android";
22
+ if (/(iPhone|iPad|iPod)/i.test(ua))
23
+ return "iOS";
24
+ if (/Linux/i.test(ua))
25
+ return "Linux";
26
+ return undefined;
27
+ }
28
+ function localeCountry() {
29
+ if (typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function") {
30
+ return undefined;
31
+ }
32
+ try {
33
+ const locale = Intl.DateTimeFormat().resolvedOptions().locale;
34
+ const match = locale.match(/[-_]([A-Za-z]{2})\b/);
35
+ if (!match)
36
+ return undefined;
37
+ return match[1].toUpperCase();
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ /** Best-effort device + country from the runtime environment. */
44
+ export function readDeviceContext() {
45
+ const out = {};
46
+ if (typeof navigator !== "undefined" && navigator.userAgent) {
47
+ const ua = navigator.userAgent;
48
+ out.device_browser = parseBrowser(ua);
49
+ out.device_os = parseOs(ua);
50
+ }
51
+ out.country = localeCountry();
52
+ return out;
53
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import { SDK_VERSION } from "./version.js";
2
2
  export { SDK_VERSION };
3
+ export { toReportableError } from "./to-reportable-error.js";
4
+ export { scrubPiiText, scrubPiiRecord } from "./pii-scrub.js";
5
+ export { WEB_VITAL_EVENT_NAME, installWebVitals, rateWebVital, buildWebVitalProperties, setWebVitalsCaptureEnabled, isWebVitalsCaptureEnabled, type WebVitalEventProperties, type WebVitalMetricName, type WebVitalRating, } from "./web-vitals.js";
3
6
  export declare function getAnonymousId(): string;
7
+ export type TelemetryPiiScrubConfig = boolean | {
8
+ /** Extra property/context keys to redact (case-insensitive). */
9
+ denyKeys?: string[];
10
+ };
4
11
  export type TelemetryConfig = {
5
12
  ingestUrl: string;
6
13
  app: string;
@@ -13,19 +20,39 @@ export type TelemetryConfig = {
13
20
  batchInterval?: number;
14
21
  /** Max queue size before flush. Default 10. */
15
22
  batchSize?: number;
23
+ /** Capture Core Web Vitals (LCP, INP, CLS, TTFB) in browser. Default true. */
24
+ webVitals?: boolean;
25
+ /**
26
+ * Optional client-side PII scrubbing before send (default off).
27
+ * Complements server ingest scrubbing — never rely on this alone.
28
+ * See https://github.com/Telemetry-Tracker/telemetry-tracker/blob/develop/docs/PII-SCRUBBING.md
29
+ */
30
+ piiScrub?: TelemetryPiiScrubConfig;
16
31
  };
17
32
  export declare function getSessionId(): string | null;
18
33
  /** End the current session and clear the in-memory session id. */
19
34
  export declare function endSession(): void;
20
35
  export declare function init(c: TelemetryConfig): void;
21
- export declare function identify(id: string | null): void;
36
+ /**
37
+ * Stop ingesting: flush/end session, clear config and batch timer.
38
+ * Browser error and session listeners stay installed but no-op while config is unset.
39
+ */
40
+ export declare function shutdown(): void;
41
+ export type IdentifyTraits = {
42
+ email?: string | null;
43
+ };
44
+ export declare function identify(id: string | null, traits?: IdentifyTraits): void;
22
45
  /** Headers for ingest POST requests (Authorization + Content-Type). */
23
46
  export declare function buildIngestHeaders(cfg: Pick<TelemetryConfig, "apiKey">): Record<string, string>;
47
+ declare function resolveClientPiiScrub(cfg: TelemetryConfig | null): {
48
+ denyKeys?: string[];
49
+ } | null;
50
+ /** @internal exported for tests */
51
+ export { resolveClientPiiScrub };
24
52
  export declare function trackEvent(name: string, properties?: Record<string, unknown>): void;
25
- export declare function trackError(error: Error | {
26
- message: string;
27
- stack?: string;
28
- }, context?: Record<string, unknown>): void;
53
+ export declare function trackError(error: unknown, context?: Record<string, unknown>): void;
54
+ /** Send an error and resolve after the ingest request settles. Fatal handlers await this. */
55
+ export declare function ingestError(error: unknown, context?: Record<string, unknown>): Promise<void>;
29
56
  export declare function screen(name: string): void;
30
57
  export declare function getUserId(): string | null;
31
58
  export declare function getConfigOrNull(): TelemetryConfig | null;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAE3C,OAAO,EAAE,WAAW,EAAE,CAAC;AAiBvB,wBAAgB,cAAc,IAAI,MAAM,CAkBvC;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,sFAAsF;IACtF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AA6GF,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED,kEAAkE;AAClE,wBAAgB,UAAU,IAAI,IAAI,CAOjC;AA4CD,wBAAgB,IAAI,CAAC,CAAC,EAAE,eAAe,GAAG,IAAI,CAgB7C;AAED,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAEhD;AAYD,uEAAuE;AACvE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,QAAQ,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAO/F;AA4DD,wBAAgB,UAAU,CACxB,IAAI,EAAE,MAAM,EACZ,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,IAAI,CAqBN;AAED,wBAAgB,UAAU,CACxB,KAAK,EAAE,KAAK,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAClD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,IAAI,CAcN;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAEzC;AAED,wBAAgB,SAAS,IAAI,MAAM,GAAG,IAAI,CAEzC;AAED,wBAAgB,eAAe,IAAI,eAAe,GAAG,IAAI,CAExD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAG3C,OAAO,EAAE,WAAW,EAAE,CAAC;AACvB,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC9D,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,YAAY,EACZ,uBAAuB,EACvB,0BAA0B,EAC1B,yBAAyB,EACzB,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,EACvB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AAyCzB,wBAAgB,cAAc,IAAI,MAAM,CAkBvC;AAED,MAAM,MAAM,uBAAuB,GAC/B,OAAO,GACP;IACE,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB,CAAC;AAEN,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,sFAAsF;IACtF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,uBAAuB,CAAC;CACpC,CAAC;AA0HF,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED,kEAAkE;AAClE,wBAAgB,UAAU,IAAI,IAAI,CAOjC;AA4CD,wBAAgB,IAAI,CAAC,CAAC,EAAE,eAAe,GAAG,IAAI,CAwB7C;AAED;;;GAGG;AACH,wBAAgB,QAAQ,IAAI,IAAI,CAS/B;AAQD,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC,EAAE,cAAc,GAAG,IAAI,CASzE;AAYD,uEAAuE;AACvE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,QAAQ,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAO/F;AA6ED,iBAAS,qBAAqB,CAC5B,GAAG,EAAE,eAAe,GAAG,IAAI,GAC1B;IAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAahC;AAED,mCAAmC;AACnC,OAAO,EAAE,qBAAqB,EAAE,CAAC;AAWjC,wBAAgB,UAAU,CACxB,IAAI,EAAE,MAAM,EACZ,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,IAAI,CAsBN;AAED,wBAAgB,UAAU,CACxB,KAAK,EAAE,OAAO,EACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,IAAI,CAEN;AAED,6FAA6F;AAC7F,wBAAgB,WAAW,CACzB,KAAK,EAAE,OAAO,EACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,OAAO,CAAC,IAAI,CAAC,CA+Cf;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAEzC;AAED,wBAAgB,SAAS,IAAI,MAAM,GAAG,IAAI,CAEzC;AAED,wBAAgB,eAAe,IAAI,eAAe,GAAG,IAAI,CAExD"}
package/dist/index.js CHANGED
@@ -1,17 +1,44 @@
1
- const REPORTED = Symbol.for("telemetry.reported");
1
+ import { readDeviceContext } from "./device-context.js";
2
+ import { installWebVitals, setWebVitalsCaptureEnabled, WEB_VITAL_EVENT_NAME, } from "./web-vitals.js";
3
+ import { scrubPiiRecord, scrubPiiText } from "./pii-scrub.js";
2
4
  import { SDK_VERSION } from "./version.js";
5
+ import { toReportableError } from "./to-reportable-error.js";
3
6
  export { SDK_VERSION };
7
+ export { toReportableError } from "./to-reportable-error.js";
8
+ export { scrubPiiText, scrubPiiRecord } from "./pii-scrub.js";
9
+ export { WEB_VITAL_EVENT_NAME, installWebVitals, rateWebVital, buildWebVitalProperties, setWebVitalsCaptureEnabled, isWebVitalsCaptureEnabled, } from "./web-vitals.js";
4
10
  const ANON_STORAGE_KEY = "tacko_telemetry_anon_id";
11
+ /** In-flight ingest promises so a later fatal flush can await trackError(e); throw e. */
12
+ const inFlightIngest = new WeakMap();
13
+ /** Completed error reports — WeakSet so we never mutate frozen/sealed Error objects. */
14
+ const reportedErrors = new WeakSet();
5
15
  let anonymousId = null;
16
+ let fallbackIdSeq = 0;
17
+ function bytesToUuid(bytes) {
18
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
19
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
20
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
21
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
22
+ }
23
+ /** Prefer Web Crypto; fall back for Node/RN hosts without a global crypto polyfill. */
6
24
  function generateUUID() {
7
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
8
- return crypto.randomUUID();
25
+ const webCrypto = typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
26
+ if (webCrypto && typeof webCrypto.randomUUID === "function") {
27
+ return webCrypto.randomUUID();
9
28
  }
10
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
11
- const r = (Math.random() * 16) | 0;
12
- const v = c === "x" ? r : (r & 0x3) | 0x8;
13
- return v.toString(16);
14
- });
29
+ if (webCrypto && typeof webCrypto.getRandomValues === "function") {
30
+ const bytes = new Uint8Array(16);
31
+ webCrypto.getRandomValues(bytes);
32
+ return bytesToUuid(bytes);
33
+ }
34
+ // Constrained runtimes: unique analytics IDs without Math.random (CodeQL insecure-randomness).
35
+ const bytes = new Uint8Array(16);
36
+ let n = (Date.now() ^ (++fallbackIdSeq * 0x9e3779b9)) >>> 0;
37
+ for (let i = 0; i < 16; i++) {
38
+ n = (Math.imul(n, 1664525) + 1013904223) >>> 0;
39
+ bytes[i] = n & 0xff;
40
+ }
41
+ return bytesToUuid(bytes);
15
42
  }
16
43
  export function getAnonymousId() {
17
44
  if (anonymousId)
@@ -37,6 +64,7 @@ export function getAnonymousId() {
37
64
  }
38
65
  let config = null;
39
66
  let userId = null;
67
+ let userEmail = null;
40
68
  let browserHandlersInstalled = false;
41
69
  let sessionLifecycleInstalled = false;
42
70
  let sessionId = null;
@@ -50,14 +78,20 @@ function ensureUrlFromCfg(cfg, path) {
50
78
  return `${base}${path}`;
51
79
  }
52
80
  function buildSessionPayload(cfg, endedAt) {
81
+ const device = readDeviceContext();
53
82
  return {
54
83
  session_id: sessionId,
55
84
  app: cfg.app,
56
85
  platform: cfg.platform ?? undefined,
57
86
  environment: cfg.environment ?? undefined,
87
+ release: cfg.release ?? undefined,
58
88
  user_id: userId ?? undefined,
89
+ user_email: userEmail ?? undefined,
59
90
  anonymous_id: getAnonymousId(),
60
91
  sdk_version: SDK_VERSION,
92
+ country: device.country ?? undefined,
93
+ device_browser: device.device_browser ?? undefined,
94
+ device_os: device.device_os ?? undefined,
61
95
  started_at: sessionStartedAt?.toISOString(),
62
96
  ended_at: endedAt?.toISOString(),
63
97
  };
@@ -102,12 +136,12 @@ function closeSessionKeepalive(endedAt) {
102
136
  sessionStartedAt = null;
103
137
  }
104
138
  function startSession() {
139
+ const cfg = getConfigOrNull();
140
+ if (!cfg)
141
+ return;
105
142
  sessionId = generateUUID();
106
143
  sessionStartedAt = new Date();
107
- const cfg = getConfigOrNull();
108
- if (cfg) {
109
- void postSession(cfg);
110
- }
144
+ void postSession(cfg);
111
145
  }
112
146
  function installBrowserSessionLifecycle() {
113
147
  if (sessionLifecycleInstalled)
@@ -120,8 +154,14 @@ function installBrowserSessionLifecycle() {
120
154
  window.addEventListener("pagehide", (event) => {
121
155
  if (event.persisted)
122
156
  return;
157
+ flushEventsKeepalive();
123
158
  closeSessionKeepalive(new Date());
124
159
  });
160
+ document.addEventListener("visibilitychange", () => {
161
+ if (document.visibilityState === "hidden") {
162
+ flushEventsKeepalive();
163
+ }
164
+ });
125
165
  window.addEventListener("pageshow", (event) => {
126
166
  if (event.persisted)
127
167
  return;
@@ -176,6 +216,10 @@ function installBrowserErrorHandlers() {
176
216
  });
177
217
  }
178
218
  export function init(c) {
219
+ if (flushTimer) {
220
+ clearInterval(flushTimer);
221
+ flushTimer = null;
222
+ }
179
223
  if (sessionId) {
180
224
  endSession();
181
225
  }
@@ -192,9 +236,39 @@ export function init(c) {
192
236
  installBrowserErrorHandlers();
193
237
  startSession();
194
238
  installBrowserSessionLifecycle();
239
+ setWebVitalsCaptureEnabled(c.webVitals !== false);
240
+ if (c.webVitals !== false) {
241
+ installBrowserWebVitals();
242
+ }
195
243
  }
196
- export function identify(id) {
244
+ /**
245
+ * Stop ingesting: flush/end session, clear config and batch timer.
246
+ * Browser error and session listeners stay installed but no-op while config is unset.
247
+ */
248
+ export function shutdown() {
249
+ flushEvents();
250
+ if (flushTimer) {
251
+ clearInterval(flushTimer);
252
+ flushTimer = null;
253
+ }
254
+ endSession();
255
+ config = null;
256
+ setWebVitalsCaptureEnabled(false);
257
+ }
258
+ function installBrowserWebVitals() {
259
+ installWebVitals((properties) => {
260
+ trackEvent(WEB_VITAL_EVENT_NAME, properties);
261
+ });
262
+ }
263
+ export function identify(id, traits) {
197
264
  userId = id;
265
+ if (traits && "email" in traits) {
266
+ userEmail = traits.email ?? null;
267
+ }
268
+ const cfg = getConfigOrNull();
269
+ if (cfg && sessionId) {
270
+ void postSession(cfg);
271
+ }
198
272
  }
199
273
  function getConfig() {
200
274
  if (!config)
@@ -251,6 +325,17 @@ function flushEvents() {
251
325
  if (!cfg || eventQueue.length === 0)
252
326
  return;
253
327
  const batch = eventQueue.splice(0, eventQueue.length);
328
+ sendEventBatch(cfg, batch, false);
329
+ }
330
+ /** Flush queued events with keepalive for tab hide / unload (CLS and short visits). */
331
+ function flushEventsKeepalive() {
332
+ const cfg = getConfigOrNull();
333
+ if (!cfg || eventQueue.length === 0)
334
+ return;
335
+ const batch = eventQueue.splice(0, eventQueue.length);
336
+ sendEventBatch(cfg, batch, true);
337
+ }
338
+ function sendEventBatch(cfg, batch, keepalive) {
254
339
  const base = cfg.ingestUrl.replace(/\/$/, "");
255
340
  const anonId = getAnonymousId();
256
341
  const events = batch.map((e) => ({
@@ -265,21 +350,52 @@ function flushEvents() {
265
350
  sdk_version: SDK_VERSION,
266
351
  properties: e.properties,
267
352
  }));
268
- fetch(`${base}/ingest/batch`, {
269
- method: "POST",
270
- headers: buildIngestHeaders(cfg),
271
- body: JSON.stringify({ events }),
272
- }).catch((e) => console.warn("[telemetry] batch send error:", e));
353
+ try {
354
+ void fetch(`${base}/ingest/batch`, {
355
+ method: "POST",
356
+ headers: buildIngestHeaders(cfg),
357
+ body: JSON.stringify({ events }),
358
+ ...(keepalive ? { keepalive: true } : {}),
359
+ }).catch((e) => console.warn("[telemetry] batch send error:", e));
360
+ }
361
+ catch (e) {
362
+ console.warn("[telemetry] batch send error:", e);
363
+ }
364
+ }
365
+ function resolveClientPiiScrub(cfg) {
366
+ // Opt-in only: omitted / false / null → no client scrubbing.
367
+ if (cfg?.piiScrub == null || cfg.piiScrub === false)
368
+ return null;
369
+ if (cfg.piiScrub === true)
370
+ return {};
371
+ if (typeof cfg.piiScrub === "object") {
372
+ // `{ denyKeys: [] }` still enables default pattern/key scrubbing (no extra keys).
373
+ return {
374
+ ...(cfg.piiScrub.denyKeys && cfg.piiScrub.denyKeys.length > 0
375
+ ? { denyKeys: cfg.piiScrub.denyKeys }
376
+ : {}),
377
+ };
378
+ }
379
+ return null;
380
+ }
381
+ /** @internal exported for tests */
382
+ export { resolveClientPiiScrub };
383
+ function scrubEventProperties(cfg, properties) {
384
+ const opts = resolveClientPiiScrub(cfg);
385
+ if (!opts || properties === undefined)
386
+ return properties;
387
+ return scrubPiiRecord(properties, opts);
273
388
  }
274
389
  export function trackEvent(name, properties) {
275
390
  const cfg = getConfigOrNull();
276
391
  const interval = cfg?.batchInterval ?? DEFAULT_BATCH_INTERVAL;
277
392
  const batchSize = cfg?.batchSize ?? DEFAULT_BATCH_SIZE;
393
+ const scrubbedProperties = scrubEventProperties(cfg, properties ?? undefined);
278
394
  const item = {
279
395
  name,
280
396
  user_id: userId,
281
397
  session_id: sessionId ?? undefined,
282
- properties: properties ?? undefined,
398
+ properties: scrubbedProperties,
283
399
  };
284
400
  if (interval > 0 && typeof setInterval !== "undefined") {
285
401
  eventQueue.push(item);
@@ -296,22 +412,53 @@ export function trackEvent(name, properties) {
296
412
  }
297
413
  }
298
414
  export function trackError(error, context) {
299
- if (!getConfigOrNull())
300
- return;
301
- const err = error instanceof Error ? error : { message: error.message, stack: error.stack };
302
- if (err && typeof err === "object" && err[REPORTED])
303
- return;
304
- const message = err instanceof Error ? err.message : err.message;
305
- const stack = err instanceof Error ? err.stack : err.stack;
306
- if (err instanceof Error)
307
- err[REPORTED] = true;
308
- send("/ingest/error", {
309
- message,
310
- stack: stack ?? undefined,
311
- context: context ?? undefined,
312
- user_id: userId ?? undefined,
313
- session_id: sessionId ?? undefined,
314
- }).catch(() => { });
415
+ void ingestError(error, context);
416
+ }
417
+ /** Send an error and resolve after the ingest request settles. Fatal handlers await this. */
418
+ export function ingestError(error, context) {
419
+ try {
420
+ const cfg = getConfigOrNull();
421
+ if (!cfg)
422
+ return Promise.resolve();
423
+ const err = toReportableError(error);
424
+ const existing = inFlightIngest.get(err);
425
+ if (existing)
426
+ return existing;
427
+ if (reportedErrors.has(err)) {
428
+ return Promise.resolve();
429
+ }
430
+ let message = err.message;
431
+ let stack = err.stack;
432
+ let scrubbedContext = context ?? undefined;
433
+ const scrubOpts = resolveClientPiiScrub(cfg);
434
+ if (scrubOpts) {
435
+ message = scrubPiiText(message);
436
+ if (stack != null)
437
+ stack = scrubPiiText(stack);
438
+ if (scrubbedContext != null) {
439
+ scrubbedContext = scrubPiiRecord(scrubbedContext, scrubOpts);
440
+ }
441
+ }
442
+ // Non-mutating dedupe — safe for Object.freeze / seal / preventExtensions.
443
+ reportedErrors.add(err);
444
+ const pending = send("/ingest/error", {
445
+ message,
446
+ stack: stack ?? undefined,
447
+ context: scrubbedContext,
448
+ user_id: userId ?? undefined,
449
+ session_id: sessionId ?? undefined,
450
+ })
451
+ .catch(() => { })
452
+ .finally(() => {
453
+ inFlightIngest.delete(err);
454
+ });
455
+ inFlightIngest.set(err, pending);
456
+ return pending;
457
+ }
458
+ catch {
459
+ // Never let telemetry-internal failures escape into the caller's crash path.
460
+ return Promise.resolve();
461
+ }
315
462
  }
316
463
  export function screen(name) {
317
464
  trackEvent("$screen", { name });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Optional client-side PII scrubbing for @telemetry-tracker/core.
3
+ * Complements server ingest scrubbing; never replaces it.
4
+ */
5
+ export type ClientPiiScrubOptions = {
6
+ denyKeys?: string[];
7
+ /** Max object/array nesting depth (default 8). */
8
+ maxDepth?: number;
9
+ /**
10
+ * Soft threshold: when exceeded, switch to the bounded remainder pass
11
+ * (still redacts nested keys/strings). Default 500.
12
+ */
13
+ maxNodes?: number;
14
+ };
15
+ /** Scrub PII patterns in free-form text. Preserves newlines. */
16
+ export declare function scrubPiiText(text: string): string;
17
+ export declare function scrubPiiValue(value: unknown, options?: ClientPiiScrubOptions): unknown;
18
+ export declare function scrubPiiRecord(record: Record<string, unknown> | undefined, options?: ClientPiiScrubOptions): Record<string, unknown> | undefined;
19
+ //# sourceMappingURL=pii-scrub.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pii-scrub.d.ts","sourceRoot":"","sources":["../src/pii-scrub.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AA4DF,gEAAgE;AAChE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAgBjD;AA4CD,wBAAgB,aAAa,CAC3B,KAAK,EAAE,OAAO,EACd,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAoDT;AAED,wBAAgB,cAAc,CAC5B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC3C,OAAO,CAAC,EAAE,qBAAqB,GAC9B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAGrC"}
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Optional client-side PII scrubbing for @telemetry-tracker/core.
3
+ * Complements server ingest scrubbing; never replaces it.
4
+ */
5
+ const DEFAULT_MAX_DEPTH = 8;
6
+ const DEFAULT_MAX_NODES = 500;
7
+ const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
8
+ const JWT_RE = /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g;
9
+ const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/=-]{4,}\b/gi;
10
+ const API_KEY_RE = /\b(?:tt_live_[A-Za-z0-9_]+|sk-[A-Za-z0-9]{16,}|pk-[A-Za-z0-9]{16,})\b/g;
11
+ const SENSITIVE_PARAM_RE = /([?&](?:password|token|secret|api[_-]?key|access[_-]?token|auth|session|cookie)=)[^&\s]+/gi;
12
+ const SENSITIVE_ASSIGNMENT_RE = /\b(?:password|token|secret|api[_-]?key|access[_-]?token|auth|session|cookie)\s*=\s*[^\s&]+/gi;
13
+ const COOKIE_HEADER_RE = /\b(cookie|set-cookie)\s*:\s*[^\n]+/gi;
14
+ const AUTHORIZATION_HEADER_RE = /\bauthorization\s*:\s*[^\n]+/gi;
15
+ const SENSITIVE_KEY_PLACEHOLDERS = {
16
+ email: "[email]",
17
+ useremail: "[email]",
18
+ mail: "[email]",
19
+ phone: "[phone]",
20
+ phonenumber: "[phone]",
21
+ mobile: "[phone]",
22
+ ssn: "[ssn]",
23
+ password: "[password]",
24
+ secret: "[secret]",
25
+ token: "[token]",
26
+ accesstoken: "[token]",
27
+ refreshtoken: "[token]",
28
+ apikey: "[api-key]",
29
+ authorization: "[bearer-token]",
30
+ cookie: "[cookie]",
31
+ setcookie: "[cookie]",
32
+ };
33
+ function normalizeKey(key) {
34
+ return key.toLowerCase().replace(/[^a-z0-9]/g, "");
35
+ }
36
+ function denyKeySet(denyKeys) {
37
+ if (!denyKeys?.length)
38
+ return EMPTY;
39
+ const set = new Set();
40
+ for (const key of denyKeys) {
41
+ const n = normalizeKey(key);
42
+ if (n)
43
+ set.add(n);
44
+ }
45
+ return set;
46
+ }
47
+ const EMPTY = new Set();
48
+ function resolvePlaceholder(key, denyNormalized) {
49
+ const n = normalizeKey(key);
50
+ return SENSITIVE_KEY_PLACEHOLDERS[n] ?? (denyNormalized.has(n) ? "[redacted]" : null);
51
+ }
52
+ /** Scrub PII patterns in free-form text. Preserves newlines. */
53
+ export function scrubPiiText(text) {
54
+ let out = text;
55
+ out = out.replace(EMAIL_RE, "[email]");
56
+ out = out.replace(JWT_RE, "[token]");
57
+ out = out.replace(BEARER_RE, "[bearer-token]");
58
+ out = out.replace(API_KEY_RE, "[api-key]");
59
+ out = scrubPaymentCardNumbers(out);
60
+ out = scrubPhoneNumbers(out);
61
+ out = out.replace(SENSITIVE_PARAM_RE, "$1[redacted]");
62
+ out = out.replace(SENSITIVE_ASSIGNMENT_RE, (match) => `${match.split("=")[0].trim()}=[redacted]`);
63
+ out = out.replace(COOKIE_HEADER_RE, (_, header) => `${header}: [cookie]`);
64
+ out = out.replace(AUTHORIZATION_HEADER_RE, "authorization: [bearer-token]");
65
+ return out;
66
+ }
67
+ function passesLuhn(digits) {
68
+ if (!/^\d{13,19}$/.test(digits))
69
+ return false;
70
+ let sum = 0;
71
+ let alternate = false;
72
+ for (let i = digits.length - 1; i >= 0; i--) {
73
+ let n = digits.charCodeAt(i) - 48;
74
+ if (alternate) {
75
+ n *= 2;
76
+ if (n > 9)
77
+ n -= 9;
78
+ }
79
+ sum += n;
80
+ alternate = !alternate;
81
+ }
82
+ return sum % 10 === 0;
83
+ }
84
+ function scrubPaymentCardNumbers(text) {
85
+ return text.replace(/(?<![A-Za-z0-9])(?:\d[ -]*?){13,19}(?![A-Za-z0-9])/g, (match) => {
86
+ const digits = match.replace(/\D/g, "");
87
+ return passesLuhn(digits) ? "[card]" : match;
88
+ });
89
+ }
90
+ function scrubPhoneNumbers(text) {
91
+ let out = text;
92
+ // Plus-prefixed: + and 7–15 digits with optional spaces/dashes between digits
93
+ // Dots excluded so version-like 123.456.7890 is not treated as a phone.
94
+ out = out.replace(/(?<![\w])\+[1-9](?:[\s-]*\d){6,14}(?![\d])/g, "[phone]");
95
+ // North American style with separators (space/dash only; no dots)
96
+ out = out.replace(/(?<![\w])(?:\+?1[\s-]?)?\(?\d{3}\)?[\s-]\d{3}[\s-]\d{4}(?![\d])/g, "[phone]");
97
+ return out;
98
+ }
99
+ export function scrubPiiValue(value, options) {
100
+ const maxDepth = options?.maxDepth ?? DEFAULT_MAX_DEPTH;
101
+ const maxNodes = options?.maxNodes ?? DEFAULT_MAX_NODES;
102
+ const denyNormalized = denyKeySet(options?.denyKeys);
103
+ let nodes = 0;
104
+ /** Bounded recursive pass after soft limits — never returns nested structures unchanged. */
105
+ function scrubRemainder(node, hardCap) {
106
+ hardCap.n += 1;
107
+ // Absolute ceiling so limit fallback cannot hang the client.
108
+ if (hardCap.n > 10000) {
109
+ return typeof node === "string" ? scrubPiiText(node) : "[truncated]";
110
+ }
111
+ if (typeof node === "string")
112
+ return scrubPiiText(node);
113
+ if (Array.isArray(node)) {
114
+ return node.map((item) => scrubRemainder(item, hardCap));
115
+ }
116
+ if (node && typeof node === "object") {
117
+ const out = {};
118
+ for (const [key, child] of Object.entries(node)) {
119
+ const placeholder = resolvePlaceholder(key, denyNormalized);
120
+ out[key] =
121
+ placeholder != null ? placeholder : scrubRemainder(child, hardCap);
122
+ }
123
+ return out;
124
+ }
125
+ return node;
126
+ }
127
+ function walk(node, depth) {
128
+ nodes += 1;
129
+ if (nodes > maxNodes || depth > maxDepth) {
130
+ return scrubRemainder(node, { n: 0 });
131
+ }
132
+ if (node == null || typeof node === "boolean" || typeof node === "number") {
133
+ return node;
134
+ }
135
+ if (typeof node === "string")
136
+ return scrubPiiText(node);
137
+ if (Array.isArray(node))
138
+ return node.map((item) => walk(item, depth + 1));
139
+ if (typeof node === "object") {
140
+ const out = {};
141
+ for (const [key, child] of Object.entries(node)) {
142
+ const placeholder = resolvePlaceholder(key, denyNormalized);
143
+ out[key] =
144
+ placeholder != null ? placeholder : walk(child, depth + 1);
145
+ }
146
+ return out;
147
+ }
148
+ return node;
149
+ }
150
+ return walk(value, 0);
151
+ }
152
+ export function scrubPiiRecord(record, options) {
153
+ if (record == null)
154
+ return undefined;
155
+ return scrubPiiValue(record, options);
156
+ }
@@ -0,0 +1,3 @@
1
+ /** Normalize any thrown/rejected value into a reportable Error. */
2
+ export declare function toReportableError(value: unknown): Error;
3
+ //# sourceMappingURL=to-reportable-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"to-reportable-error.d.ts","sourceRoot":"","sources":["../src/to-reportable-error.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,CAuBvD"}
@@ -0,0 +1,30 @@
1
+ /** Normalize any thrown/rejected value into a reportable Error. */
2
+ export function toReportableError(value) {
3
+ if (value instanceof Error)
4
+ return value;
5
+ if (value === null)
6
+ return new Error("null");
7
+ if (value === undefined)
8
+ return new Error("undefined");
9
+ if (typeof value === "string")
10
+ return new Error(value);
11
+ if (typeof value === "number" ||
12
+ typeof value === "boolean" ||
13
+ typeof value === "bigint") {
14
+ return new Error(String(value));
15
+ }
16
+ if (typeof value === "symbol")
17
+ return new Error(value.toString());
18
+ if (typeof value === "object") {
19
+ try {
20
+ const json = JSON.stringify(value);
21
+ if (typeof json === "string")
22
+ return new Error(json);
23
+ }
24
+ catch {
25
+ // circular / non-serializable
26
+ }
27
+ return new Error(Object.prototype.toString.call(value));
28
+ }
29
+ return new Error(String(value));
30
+ }
package/dist/version.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Injected at build time from package.json. Do not edit manually. */
2
- export declare const SDK_VERSION = "1.3.0";
2
+ export declare const SDK_VERSION = "1.5.0";
3
3
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Injected at build time from package.json. Do not edit manually. */
2
- export const SDK_VERSION = "1.3.0";
2
+ export const SDK_VERSION = "1.5.0";
@@ -0,0 +1,24 @@
1
+ /** Core Web Vitals capture for browser apps (issue #193). */
2
+ import type { Metric } from "web-vitals";
3
+ export declare const WEB_VITAL_EVENT_NAME = "$web_vital";
4
+ export type WebVitalMetricName = "LCP" | "INP" | "CLS" | "TTFB" | "FID";
5
+ export type WebVitalRating = "good" | "needs-improvement" | "poor";
6
+ export type WebVitalEventProperties = {
7
+ metric: WebVitalMetricName;
8
+ value: number;
9
+ rating: WebVitalRating;
10
+ path: string;
11
+ id: string;
12
+ navigation_type?: string;
13
+ connection_type?: string;
14
+ };
15
+ export declare function rateWebVital(metric: WebVitalMetricName, value: number): WebVitalRating;
16
+ export declare function buildWebVitalProperties(metric: Metric): WebVitalEventProperties;
17
+ /** Toggle capture without unregistering library listeners (checked on every report). */
18
+ export declare function setWebVitalsCaptureEnabled(enabled: boolean): void;
19
+ export declare function isWebVitalsCaptureEnabled(): boolean;
20
+ /** Register Core Web Vitals listeners; calls `report` once per finalized metric sample. */
21
+ export declare function installWebVitals(report: (properties: WebVitalEventProperties) => void): void;
22
+ /** @internal Test helper — reset install guard. */
23
+ export declare function resetWebVitalsInstallState(): void;
24
+ //# sourceMappingURL=web-vitals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-vitals.d.ts","sourceRoot":"","sources":["../src/web-vitals.ts"],"names":[],"mappings":"AAAA,6DAA6D;AAE7D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAEzC,eAAO,MAAM,oBAAoB,eAAe,CAAC;AAEjD,MAAM,MAAM,kBAAkB,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,CAAC;AAExE,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,mBAAmB,GAAG,MAAM,CAAC;AAEnE,MAAM,MAAM,uBAAuB,GAAG;IACpC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,cAAc,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AAWF,wBAAgB,YAAY,CAAC,MAAM,EAAE,kBAAkB,EAAE,KAAK,EAAE,MAAM,GAAG,cAAc,CAKtF;AAcD,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,uBAAuB,CAW/E;AAKD,wFAAwF;AACxF,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAEjE;AAED,wBAAgB,yBAAyB,IAAI,OAAO,CAEnD;AAED,2FAA2F;AAC3F,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,CAAC,UAAU,EAAE,uBAAuB,KAAK,IAAI,GACpD,IAAI,CA8BN;AAED,mDAAmD;AACnD,wBAAgB,0BAA0B,IAAI,IAAI,CAGjD"}
@@ -0,0 +1,87 @@
1
+ /** Core Web Vitals capture for browser apps (issue #193). */
2
+ export const WEB_VITAL_EVENT_NAME = "$web_vital";
3
+ /** Google Web Vitals thresholds — used in tests and when rating is computed locally. */
4
+ const THRESHOLDS = {
5
+ LCP: [2500, 4000],
6
+ INP: [200, 500],
7
+ CLS: [0.1, 0.25],
8
+ TTFB: [800, 1800],
9
+ FID: [100, 300],
10
+ };
11
+ export function rateWebVital(metric, value) {
12
+ const [goodMax, poorMin] = THRESHOLDS[metric];
13
+ if (value <= goodMax)
14
+ return "good";
15
+ if (value >= poorMin)
16
+ return "poor";
17
+ return "needs-improvement";
18
+ }
19
+ function readPagePath() {
20
+ if (typeof window === "undefined")
21
+ return "/";
22
+ return window.location.pathname || "/";
23
+ }
24
+ function readConnectionType() {
25
+ if (typeof navigator === "undefined")
26
+ return undefined;
27
+ const conn = navigator
28
+ .connection;
29
+ return conn?.effectiveType?.trim() || undefined;
30
+ }
31
+ export function buildWebVitalProperties(metric) {
32
+ const name = metric.name;
33
+ return {
34
+ metric: name,
35
+ value: metric.value,
36
+ rating: metric.rating,
37
+ path: readPagePath(),
38
+ id: metric.id,
39
+ navigation_type: metric.navigationType,
40
+ connection_type: readConnectionType(),
41
+ };
42
+ }
43
+ let webVitalsInstalled = false;
44
+ let webVitalsCaptureEnabled = false;
45
+ /** Toggle capture without unregistering library listeners (checked on every report). */
46
+ export function setWebVitalsCaptureEnabled(enabled) {
47
+ webVitalsCaptureEnabled = enabled;
48
+ }
49
+ export function isWebVitalsCaptureEnabled() {
50
+ return webVitalsCaptureEnabled;
51
+ }
52
+ /** Register Core Web Vitals listeners; calls `report` once per finalized metric sample. */
53
+ export function installWebVitals(report) {
54
+ if (webVitalsInstalled)
55
+ return;
56
+ if (typeof window === "undefined" ||
57
+ typeof document === "undefined" ||
58
+ typeof window.addEventListener !== "function") {
59
+ return;
60
+ }
61
+ webVitalsInstalled = true;
62
+ void import("web-vitals")
63
+ .then(({ onCLS, onINP, onLCP, onTTFB }) => {
64
+ if (!webVitalsCaptureEnabled) {
65
+ webVitalsInstalled = false;
66
+ return;
67
+ }
68
+ const handle = (metric) => {
69
+ if (!webVitalsCaptureEnabled)
70
+ return;
71
+ report(buildWebVitalProperties(metric));
72
+ };
73
+ onCLS(handle);
74
+ onINP(handle);
75
+ onLCP(handle);
76
+ onTTFB(handle);
77
+ })
78
+ .catch((err) => {
79
+ console.warn("[telemetry] web vitals install failed:", err);
80
+ webVitalsInstalled = false;
81
+ });
82
+ }
83
+ /** @internal Test helper — reset install guard. */
84
+ export function resetWebVitalsInstallState() {
85
+ webVitalsInstalled = false;
86
+ webVitalsCaptureEnabled = false;
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telemetry-tracker/core",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Lightweight telemetry client: events, errors, sessions. Framework-agnostic core.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,6 +31,10 @@
31
31
  "typescript": "^5.6.3",
32
32
  "vitest": "^3.2.6"
33
33
  },
34
+ "dependencies": {
35
+ "web-vitals": "^4.2.4"
36
+ },
37
+ "gitHead": "886d9f8e0b92a78471f27b87365fc40ba7043ce1",
34
38
  "scripts": {
35
39
  "build": "node scripts/inject-version.cjs && rm -rf dist && tsc",
36
40
  "dev": "tsc --watch",