@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.
- package/dist/device-context.d.ts +9 -0
- package/dist/device-context.d.ts.map +1 -0
- package/dist/device-context.js +53 -0
- package/dist/index.d.ts +28 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +121 -16
- package/dist/pii-scrub.d.ts +19 -0
- package/dist/pii-scrub.d.ts.map +1 -0
- package/dist/pii-scrub.js +156 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/web-vitals.d.ts +24 -0
- package/dist/web-vitals.d.ts.map +1 -0
- package/dist/web-vitals.js +87 -0
- package/package.json +4 -1
|
@@ -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
|
-
|
|
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;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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:
|
|
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
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.
|
|
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
|
+
"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",
|