@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.
- 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 +32 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +182 -35
- 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/to-reportable-error.d.ts +3 -0
- package/dist/to-reportable-error.d.ts.map +1 -0
- package/dist/to-reportable-error.js +30 -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 +5 -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,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
|
-
|
|
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:
|
|
26
|
-
|
|
27
|
-
|
|
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;
|
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;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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
25
|
+
const webCrypto = typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
|
|
26
|
+
if (webCrypto && typeof webCrypto.randomUUID === "function") {
|
|
27
|
+
return webCrypto.randomUUID();
|
|
9
28
|
}
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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:
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
err
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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 @@
|
|
|
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
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.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
|
+
"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",
|