@telemetry-tracker/core 1.3.0 → 1.4.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,12 @@
1
1
  import { SDK_VERSION } from "./version.js";
2
2
  export { SDK_VERSION };
3
+ export { scrubPiiText, scrubPiiRecord } from "./pii-scrub.js";
4
+ export { WEB_VITAL_EVENT_NAME, installWebVitals, rateWebVital, buildWebVitalProperties, setWebVitalsCaptureEnabled, isWebVitalsCaptureEnabled, type WebVitalEventProperties, type WebVitalMetricName, type WebVitalRating, } from "./web-vitals.js";
3
5
  export declare function getAnonymousId(): string;
6
+ export type TelemetryPiiScrubConfig = boolean | {
7
+ /** Extra property/context keys to redact (case-insensitive). */
8
+ denyKeys?: string[];
9
+ };
4
10
  export type TelemetryConfig = {
5
11
  ingestUrl: string;
6
12
  app: string;
@@ -13,14 +19,35 @@ export type TelemetryConfig = {
13
19
  batchInterval?: number;
14
20
  /** Max queue size before flush. Default 10. */
15
21
  batchSize?: number;
22
+ /** Capture Core Web Vitals (LCP, INP, CLS, TTFB) in browser. Default true. */
23
+ webVitals?: boolean;
24
+ /**
25
+ * Optional client-side PII scrubbing before send (default off).
26
+ * Complements server ingest scrubbing — never rely on this alone.
27
+ * See https://github.com/Telemetry-Tracker/telemetry-tracker/blob/develop/docs/PII-SCRUBBING.md
28
+ */
29
+ piiScrub?: TelemetryPiiScrubConfig;
16
30
  };
17
31
  export declare function getSessionId(): string | null;
18
32
  /** End the current session and clear the in-memory session id. */
19
33
  export declare function endSession(): void;
20
34
  export declare function init(c: TelemetryConfig): void;
21
- export declare function identify(id: string | null): void;
35
+ /**
36
+ * Stop ingesting: flush/end session, clear config and batch timer.
37
+ * Browser error and session listeners stay installed but no-op while config is unset.
38
+ */
39
+ export declare function shutdown(): void;
40
+ export type IdentifyTraits = {
41
+ email?: string | null;
42
+ };
43
+ export declare function identify(id: string | null, traits?: IdentifyTraits): void;
22
44
  /** Headers for ingest POST requests (Authorization + Content-Type). */
23
45
  export declare function buildIngestHeaders(cfg: Pick<TelemetryConfig, "apiKey">): Record<string, string>;
46
+ declare function resolveClientPiiScrub(cfg: TelemetryConfig | null): {
47
+ denyKeys?: string[];
48
+ } | null;
49
+ /** @internal exported for tests */
50
+ export { resolveClientPiiScrub };
24
51
  export declare function trackEvent(name: string, properties?: Record<string, unknown>): void;
25
52
  export declare function trackError(error: Error | {
26
53
  message: string;
@@ -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;AAE3C,OAAO,EAAE,WAAW,EAAE,CAAC;AACvB,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;AAmBzB,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,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,CAwBN;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,6 +1,11 @@
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";
3
5
  export { SDK_VERSION };
6
+ export { scrubPiiText, scrubPiiRecord } from "./pii-scrub.js";
7
+ export { WEB_VITAL_EVENT_NAME, installWebVitals, rateWebVital, buildWebVitalProperties, setWebVitalsCaptureEnabled, isWebVitalsCaptureEnabled, } from "./web-vitals.js";
8
+ const REPORTED = Symbol.for("telemetry.reported");
4
9
  const ANON_STORAGE_KEY = "tacko_telemetry_anon_id";
5
10
  let anonymousId = null;
6
11
  function generateUUID() {
@@ -37,6 +42,7 @@ export function getAnonymousId() {
37
42
  }
38
43
  let config = null;
39
44
  let userId = null;
45
+ let userEmail = null;
40
46
  let browserHandlersInstalled = false;
41
47
  let sessionLifecycleInstalled = false;
42
48
  let sessionId = null;
@@ -50,14 +56,20 @@ function ensureUrlFromCfg(cfg, path) {
50
56
  return `${base}${path}`;
51
57
  }
52
58
  function buildSessionPayload(cfg, endedAt) {
59
+ const device = readDeviceContext();
53
60
  return {
54
61
  session_id: sessionId,
55
62
  app: cfg.app,
56
63
  platform: cfg.platform ?? undefined,
57
64
  environment: cfg.environment ?? undefined,
65
+ release: cfg.release ?? undefined,
58
66
  user_id: userId ?? undefined,
67
+ user_email: userEmail ?? undefined,
59
68
  anonymous_id: getAnonymousId(),
60
69
  sdk_version: SDK_VERSION,
70
+ country: device.country ?? undefined,
71
+ device_browser: device.device_browser ?? undefined,
72
+ device_os: device.device_os ?? undefined,
61
73
  started_at: sessionStartedAt?.toISOString(),
62
74
  ended_at: endedAt?.toISOString(),
63
75
  };
@@ -102,12 +114,12 @@ function closeSessionKeepalive(endedAt) {
102
114
  sessionStartedAt = null;
103
115
  }
104
116
  function startSession() {
117
+ const cfg = getConfigOrNull();
118
+ if (!cfg)
119
+ return;
105
120
  sessionId = generateUUID();
106
121
  sessionStartedAt = new Date();
107
- const cfg = getConfigOrNull();
108
- if (cfg) {
109
- void postSession(cfg);
110
- }
122
+ void postSession(cfg);
111
123
  }
112
124
  function installBrowserSessionLifecycle() {
113
125
  if (sessionLifecycleInstalled)
@@ -120,8 +132,14 @@ function installBrowserSessionLifecycle() {
120
132
  window.addEventListener("pagehide", (event) => {
121
133
  if (event.persisted)
122
134
  return;
135
+ flushEventsKeepalive();
123
136
  closeSessionKeepalive(new Date());
124
137
  });
138
+ document.addEventListener("visibilitychange", () => {
139
+ if (document.visibilityState === "hidden") {
140
+ flushEventsKeepalive();
141
+ }
142
+ });
125
143
  window.addEventListener("pageshow", (event) => {
126
144
  if (event.persisted)
127
145
  return;
@@ -176,6 +194,10 @@ function installBrowserErrorHandlers() {
176
194
  });
177
195
  }
178
196
  export function init(c) {
197
+ if (flushTimer) {
198
+ clearInterval(flushTimer);
199
+ flushTimer = null;
200
+ }
179
201
  if (sessionId) {
180
202
  endSession();
181
203
  }
@@ -192,9 +214,39 @@ export function init(c) {
192
214
  installBrowserErrorHandlers();
193
215
  startSession();
194
216
  installBrowserSessionLifecycle();
217
+ setWebVitalsCaptureEnabled(c.webVitals !== false);
218
+ if (c.webVitals !== false) {
219
+ installBrowserWebVitals();
220
+ }
221
+ }
222
+ /**
223
+ * Stop ingesting: flush/end session, clear config and batch timer.
224
+ * Browser error and session listeners stay installed but no-op while config is unset.
225
+ */
226
+ export function shutdown() {
227
+ flushEvents();
228
+ if (flushTimer) {
229
+ clearInterval(flushTimer);
230
+ flushTimer = null;
231
+ }
232
+ endSession();
233
+ config = null;
234
+ setWebVitalsCaptureEnabled(false);
235
+ }
236
+ function installBrowserWebVitals() {
237
+ installWebVitals((properties) => {
238
+ trackEvent(WEB_VITAL_EVENT_NAME, properties);
239
+ });
195
240
  }
196
- export function identify(id) {
241
+ export function identify(id, traits) {
197
242
  userId = id;
243
+ if (traits && "email" in traits) {
244
+ userEmail = traits.email ?? null;
245
+ }
246
+ const cfg = getConfigOrNull();
247
+ if (cfg && sessionId) {
248
+ void postSession(cfg);
249
+ }
198
250
  }
199
251
  function getConfig() {
200
252
  if (!config)
@@ -251,6 +303,17 @@ function flushEvents() {
251
303
  if (!cfg || eventQueue.length === 0)
252
304
  return;
253
305
  const batch = eventQueue.splice(0, eventQueue.length);
306
+ sendEventBatch(cfg, batch, false);
307
+ }
308
+ /** Flush queued events with keepalive for tab hide / unload (CLS and short visits). */
309
+ function flushEventsKeepalive() {
310
+ const cfg = getConfigOrNull();
311
+ if (!cfg || eventQueue.length === 0)
312
+ return;
313
+ const batch = eventQueue.splice(0, eventQueue.length);
314
+ sendEventBatch(cfg, batch, true);
315
+ }
316
+ function sendEventBatch(cfg, batch, keepalive) {
254
317
  const base = cfg.ingestUrl.replace(/\/$/, "");
255
318
  const anonId = getAnonymousId();
256
319
  const events = batch.map((e) => ({
@@ -265,21 +328,52 @@ function flushEvents() {
265
328
  sdk_version: SDK_VERSION,
266
329
  properties: e.properties,
267
330
  }));
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));
331
+ try {
332
+ void fetch(`${base}/ingest/batch`, {
333
+ method: "POST",
334
+ headers: buildIngestHeaders(cfg),
335
+ body: JSON.stringify({ events }),
336
+ ...(keepalive ? { keepalive: true } : {}),
337
+ }).catch((e) => console.warn("[telemetry] batch send error:", e));
338
+ }
339
+ catch (e) {
340
+ console.warn("[telemetry] batch send error:", e);
341
+ }
342
+ }
343
+ function resolveClientPiiScrub(cfg) {
344
+ // Opt-in only: omitted / false / null → no client scrubbing.
345
+ if (cfg?.piiScrub == null || cfg.piiScrub === false)
346
+ return null;
347
+ if (cfg.piiScrub === true)
348
+ return {};
349
+ if (typeof cfg.piiScrub === "object") {
350
+ // `{ denyKeys: [] }` still enables default pattern/key scrubbing (no extra keys).
351
+ return {
352
+ ...(cfg.piiScrub.denyKeys && cfg.piiScrub.denyKeys.length > 0
353
+ ? { denyKeys: cfg.piiScrub.denyKeys }
354
+ : {}),
355
+ };
356
+ }
357
+ return null;
358
+ }
359
+ /** @internal exported for tests */
360
+ export { resolveClientPiiScrub };
361
+ function scrubEventProperties(cfg, properties) {
362
+ const opts = resolveClientPiiScrub(cfg);
363
+ if (!opts || properties === undefined)
364
+ return properties;
365
+ return scrubPiiRecord(properties, opts);
273
366
  }
274
367
  export function trackEvent(name, properties) {
275
368
  const cfg = getConfigOrNull();
276
369
  const interval = cfg?.batchInterval ?? DEFAULT_BATCH_INTERVAL;
277
370
  const batchSize = cfg?.batchSize ?? DEFAULT_BATCH_SIZE;
371
+ const scrubbedProperties = scrubEventProperties(cfg, properties ?? undefined);
278
372
  const item = {
279
373
  name,
280
374
  user_id: userId,
281
375
  session_id: sessionId ?? undefined,
282
- properties: properties ?? undefined,
376
+ properties: scrubbedProperties,
283
377
  };
284
378
  if (interval > 0 && typeof setInterval !== "undefined") {
285
379
  eventQueue.push(item);
@@ -296,19 +390,30 @@ export function trackEvent(name, properties) {
296
390
  }
297
391
  }
298
392
  export function trackError(error, context) {
299
- if (!getConfigOrNull())
393
+ const cfg = getConfigOrNull();
394
+ if (!cfg)
300
395
  return;
301
396
  const err = error instanceof Error ? error : { message: error.message, stack: error.stack };
302
397
  if (err && typeof err === "object" && err[REPORTED])
303
398
  return;
304
- const message = err instanceof Error ? err.message : err.message;
305
- const stack = err instanceof Error ? err.stack : err.stack;
399
+ let message = err instanceof Error ? err.message : err.message;
400
+ let stack = err instanceof Error ? err.stack : err.stack;
401
+ let scrubbedContext = context ?? undefined;
402
+ const scrubOpts = resolveClientPiiScrub(cfg);
403
+ if (scrubOpts) {
404
+ message = scrubPiiText(message);
405
+ if (stack != null)
406
+ stack = scrubPiiText(stack);
407
+ if (scrubbedContext != null) {
408
+ scrubbedContext = scrubPiiRecord(scrubbedContext, scrubOpts);
409
+ }
410
+ }
306
411
  if (err instanceof Error)
307
412
  err[REPORTED] = true;
308
413
  send("/ingest/error", {
309
414
  message,
310
415
  stack: stack ?? undefined,
311
- context: context ?? undefined,
416
+ context: scrubbedContext,
312
417
  user_id: userId ?? undefined,
313
418
  session_id: sessionId ?? undefined,
314
419
  }).catch(() => { });
@@ -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
+ }
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.4.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.4.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.4.0",
4
4
  "description": "Lightweight telemetry client: events, errors, sessions. Framework-agnostic core.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,6 +31,9 @@
31
31
  "typescript": "^5.6.3",
32
32
  "vitest": "^3.2.6"
33
33
  },
34
+ "dependencies": {
35
+ "web-vitals": "^4.2.4"
36
+ },
34
37
  "scripts": {
35
38
  "build": "node scripts/inject-version.cjs && rm -rf dist && tsc",
36
39
  "dev": "tsc --watch",