@a14y/telemetry 0.1.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/cjs/adapters/ga4-mp.d.ts +10 -0
- package/dist/cjs/adapters/ga4-mp.js +37 -0
- package/dist/cjs/adapters/noop.d.ts +2 -0
- package/dist/cjs/adapters/noop.js +9 -0
- package/dist/cjs/adapters/types.d.ts +9 -0
- package/dist/cjs/adapters/types.js +2 -0
- package/dist/cjs/core/buckets.d.ts +8 -0
- package/dist/cjs/core/buckets.js +48 -0
- package/dist/cjs/core/queue.d.ts +9 -0
- package/dist/cjs/core/queue.js +22 -0
- package/dist/cjs/core/sanitize.d.ts +8 -0
- package/dist/cjs/core/sanitize.js +58 -0
- package/dist/cjs/core/session.d.ts +16 -0
- package/dist/cjs/core/session.js +29 -0
- package/dist/cjs/core/tracker.d.ts +7 -0
- package/dist/cjs/core/tracker.js +97 -0
- package/dist/cjs/core/types.d.ts +25 -0
- package/dist/cjs/core/types.js +2 -0
- package/dist/cjs/index.d.ts +7 -0
- package/dist/cjs/index.js +29 -0
- package/dist/cjs/runtime/chromeExt.d.ts +8 -0
- package/dist/cjs/runtime/chromeExt.js +75 -0
- package/dist/cjs/runtime/node.d.ts +10 -0
- package/dist/cjs/runtime/node.js +104 -0
- package/dist/esm/adapters/ga4-mp.js +34 -0
- package/dist/esm/adapters/noop.js +6 -0
- package/dist/esm/adapters/types.js +1 -0
- package/dist/esm/core/buckets.js +42 -0
- package/dist/esm/core/queue.js +18 -0
- package/dist/esm/core/sanitize.js +53 -0
- package/dist/esm/core/session.js +23 -0
- package/dist/esm/core/tracker.js +89 -0
- package/dist/esm/core/types.js +1 -0
- package/dist/esm/index.js +11 -0
- package/dist/esm/runtime/chromeExt.js +72 -0
- package/dist/esm/runtime/node.js +98 -0
- package/package.json +49 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Adapter } from './types';
|
|
2
|
+
export interface Ga4MpAdapterOptions {
|
|
3
|
+
measurementId: string;
|
|
4
|
+
apiSecret: string;
|
|
5
|
+
/** When true, target the GA4 DebugView endpoint (validates payload, surfaces in DebugView). */
|
|
6
|
+
debug?: boolean;
|
|
7
|
+
fetchImpl?: typeof fetch;
|
|
8
|
+
endpoint?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function createGa4MpAdapter(opts: Ga4MpAdapterOptions): Adapter;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createGa4MpAdapter = createGa4MpAdapter;
|
|
4
|
+
function createGa4MpAdapter(opts) {
|
|
5
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
6
|
+
const host = opts.endpoint ?? 'https://www.google-analytics.com';
|
|
7
|
+
const path = opts.debug ? '/debug/mp/collect' : '/mp/collect';
|
|
8
|
+
return {
|
|
9
|
+
name: opts.debug ? 'ga4-mp-debug' : 'ga4-mp',
|
|
10
|
+
async send(payload) {
|
|
11
|
+
if (payload.events.length === 0)
|
|
12
|
+
return;
|
|
13
|
+
if (typeof fetchImpl !== 'function')
|
|
14
|
+
return;
|
|
15
|
+
const url = host +
|
|
16
|
+
path +
|
|
17
|
+
'?measurement_id=' +
|
|
18
|
+
encodeURIComponent(opts.measurementId) +
|
|
19
|
+
'&api_secret=' +
|
|
20
|
+
encodeURIComponent(opts.apiSecret);
|
|
21
|
+
const body = JSON.stringify({
|
|
22
|
+
client_id: payload.clientId,
|
|
23
|
+
events: payload.events.map((e) => ({ name: e.name, params: e.params })),
|
|
24
|
+
});
|
|
25
|
+
try {
|
|
26
|
+
await fetchImpl(url, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'content-type': 'application/json' },
|
|
29
|
+
body,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Network failures are swallowed.
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type ScoreBucket = '0-25' | '26-50' | '51-75' | '76-100';
|
|
2
|
+
export declare function bucketScore(n: number): ScoreBucket;
|
|
3
|
+
export type PageCountBucket = '1' | '2-10' | '11-50' | '51-200' | '200+';
|
|
4
|
+
export declare function bucketPageCount(n: number): PageCountBucket;
|
|
5
|
+
export type IssueCountBucket = '0' | '1-2' | '3-5' | '6-10' | '11+';
|
|
6
|
+
export declare function bucketIssueCount(n: number): IssueCountBucket;
|
|
7
|
+
export type DurationBucket = 'lt_5s' | '5-30s' | '30s-2m' | '2-10m' | 'gt_10m';
|
|
8
|
+
export declare function bucketDurationMs(ms: number): DurationBucket;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.bucketScore = bucketScore;
|
|
4
|
+
exports.bucketPageCount = bucketPageCount;
|
|
5
|
+
exports.bucketIssueCount = bucketIssueCount;
|
|
6
|
+
exports.bucketDurationMs = bucketDurationMs;
|
|
7
|
+
function bucketScore(n) {
|
|
8
|
+
if (!Number.isFinite(n) || n <= 25)
|
|
9
|
+
return '0-25';
|
|
10
|
+
if (n <= 50)
|
|
11
|
+
return '26-50';
|
|
12
|
+
if (n <= 75)
|
|
13
|
+
return '51-75';
|
|
14
|
+
return '76-100';
|
|
15
|
+
}
|
|
16
|
+
function bucketPageCount(n) {
|
|
17
|
+
if (!Number.isFinite(n) || n <= 1)
|
|
18
|
+
return '1';
|
|
19
|
+
if (n <= 10)
|
|
20
|
+
return '2-10';
|
|
21
|
+
if (n <= 50)
|
|
22
|
+
return '11-50';
|
|
23
|
+
if (n <= 200)
|
|
24
|
+
return '51-200';
|
|
25
|
+
return '200+';
|
|
26
|
+
}
|
|
27
|
+
function bucketIssueCount(n) {
|
|
28
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
29
|
+
return '0';
|
|
30
|
+
if (n <= 2)
|
|
31
|
+
return '1-2';
|
|
32
|
+
if (n <= 5)
|
|
33
|
+
return '3-5';
|
|
34
|
+
if (n <= 10)
|
|
35
|
+
return '6-10';
|
|
36
|
+
return '11+';
|
|
37
|
+
}
|
|
38
|
+
function bucketDurationMs(ms) {
|
|
39
|
+
if (!Number.isFinite(ms) || ms < 5000)
|
|
40
|
+
return 'lt_5s';
|
|
41
|
+
if (ms < 30000)
|
|
42
|
+
return '5-30s';
|
|
43
|
+
if (ms < 120000)
|
|
44
|
+
return '30s-2m';
|
|
45
|
+
if (ms < 600000)
|
|
46
|
+
return '2-10m';
|
|
47
|
+
return 'gt_10m';
|
|
48
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BoundedQueue = void 0;
|
|
4
|
+
/** Bounded FIFO that drops the oldest entry when full. */
|
|
5
|
+
class BoundedQueue {
|
|
6
|
+
constructor(max) {
|
|
7
|
+
this.max = max;
|
|
8
|
+
this.buf = [];
|
|
9
|
+
}
|
|
10
|
+
push(item) {
|
|
11
|
+
if (this.buf.length >= this.max)
|
|
12
|
+
this.buf.shift();
|
|
13
|
+
this.buf.push(item);
|
|
14
|
+
}
|
|
15
|
+
drain(maxBatch) {
|
|
16
|
+
return this.buf.splice(0, maxBatch);
|
|
17
|
+
}
|
|
18
|
+
get length() {
|
|
19
|
+
return this.buf.length;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
exports.BoundedQueue = BoundedQueue;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { EventParamValue } from './types';
|
|
2
|
+
export declare function errorClassName(e: unknown): string;
|
|
3
|
+
export declare function isValidEventName(name: string): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Strip PII-shaped keys, coerce values to GA4-compatible primitives, enforce
|
|
6
|
+
* GA4 length limits. Drops anything it can't safely send.
|
|
7
|
+
*/
|
|
8
|
+
export declare function sanitizeProps(props: Record<string, unknown>): Record<string, EventParamValue>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.errorClassName = errorClassName;
|
|
4
|
+
exports.isValidEventName = isValidEventName;
|
|
5
|
+
exports.sanitizeProps = sanitizeProps;
|
|
6
|
+
const PII_KEY_RE = /url|href|host|email|\bip\b/i;
|
|
7
|
+
const PATH_KEY_RE = /(^|_)path($|_)/i;
|
|
8
|
+
const GA4_EVENT_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/;
|
|
9
|
+
const MAX_PARAM_NAME = 40;
|
|
10
|
+
const MAX_STRING_VALUE = 100;
|
|
11
|
+
const MAX_PARAMS_PER_EVENT = 25;
|
|
12
|
+
const PATH_KEY_ALLOWLIST = new Set(['path_category']);
|
|
13
|
+
function errorClassName(e) {
|
|
14
|
+
if (typeof e === 'object' &&
|
|
15
|
+
e !== null &&
|
|
16
|
+
typeof e.constructor?.name === 'string') {
|
|
17
|
+
return e.constructor.name;
|
|
18
|
+
}
|
|
19
|
+
return 'Error';
|
|
20
|
+
}
|
|
21
|
+
function isValidEventName(name) {
|
|
22
|
+
return GA4_EVENT_NAME_RE.test(name);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Strip PII-shaped keys, coerce values to GA4-compatible primitives, enforce
|
|
26
|
+
* GA4 length limits. Drops anything it can't safely send.
|
|
27
|
+
*/
|
|
28
|
+
function sanitizeProps(props) {
|
|
29
|
+
const out = {};
|
|
30
|
+
let count = 0;
|
|
31
|
+
for (const [key, raw] of Object.entries(props)) {
|
|
32
|
+
if (count >= MAX_PARAMS_PER_EVENT)
|
|
33
|
+
break;
|
|
34
|
+
if (raw === undefined || raw === null)
|
|
35
|
+
continue;
|
|
36
|
+
if (key.length === 0 || key.length > MAX_PARAM_NAME)
|
|
37
|
+
continue;
|
|
38
|
+
if (PII_KEY_RE.test(key))
|
|
39
|
+
continue;
|
|
40
|
+
if (PATH_KEY_RE.test(key) && !PATH_KEY_ALLOWLIST.has(key))
|
|
41
|
+
continue;
|
|
42
|
+
let value;
|
|
43
|
+
if (typeof raw === 'boolean' || typeof raw === 'number') {
|
|
44
|
+
if (typeof raw === 'number' && !Number.isFinite(raw))
|
|
45
|
+
continue;
|
|
46
|
+
value = raw;
|
|
47
|
+
}
|
|
48
|
+
else if (typeof raw === 'string') {
|
|
49
|
+
value = raw.slice(0, MAX_STRING_VALUE);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
out[key] = value;
|
|
55
|
+
count++;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Adapter } from '../adapters/types';
|
|
2
|
+
import type { AppName, ConfigProvider } from './types';
|
|
3
|
+
export interface Session {
|
|
4
|
+
appName: AppName;
|
|
5
|
+
appVersion: string;
|
|
6
|
+
deviceId: string;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
adapter: Adapter;
|
|
10
|
+
configProvider: ConfigProvider;
|
|
11
|
+
}
|
|
12
|
+
export declare function setSession(s: Session): void;
|
|
13
|
+
export declare function getSession(): Session | null;
|
|
14
|
+
export declare function resetSession(): void;
|
|
15
|
+
/** 16-hex-char session id. Not a security boundary — uniqueness within a run is enough. */
|
|
16
|
+
export declare function newSessionId(): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.setSession = setSession;
|
|
4
|
+
exports.getSession = getSession;
|
|
5
|
+
exports.resetSession = resetSession;
|
|
6
|
+
exports.newSessionId = newSessionId;
|
|
7
|
+
let session = null;
|
|
8
|
+
function setSession(s) {
|
|
9
|
+
session = s;
|
|
10
|
+
}
|
|
11
|
+
function getSession() {
|
|
12
|
+
return session;
|
|
13
|
+
}
|
|
14
|
+
function resetSession() {
|
|
15
|
+
session = null;
|
|
16
|
+
}
|
|
17
|
+
/** 16-hex-char session id. Not a security boundary — uniqueness within a run is enough. */
|
|
18
|
+
function newSessionId() {
|
|
19
|
+
const bytes = new Uint8Array(8);
|
|
20
|
+
const g = globalThis;
|
|
21
|
+
if (g.crypto?.getRandomValues) {
|
|
22
|
+
g.crypto.getRandomValues(bytes);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
for (let i = 0; i < 8; i++)
|
|
26
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
27
|
+
}
|
|
28
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
29
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { InitOptions } from './types';
|
|
2
|
+
export declare function init(opts: InitOptions): Promise<void>;
|
|
3
|
+
export declare function track(name: string, props?: Record<string, unknown>): void;
|
|
4
|
+
export declare function flush(): Promise<void>;
|
|
5
|
+
export declare function setEnabled(enabled: boolean): Promise<void>;
|
|
6
|
+
export declare function isEnabled(): boolean;
|
|
7
|
+
export declare function shutdown(): void;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.init = init;
|
|
4
|
+
exports.track = track;
|
|
5
|
+
exports.flush = flush;
|
|
6
|
+
exports.setEnabled = setEnabled;
|
|
7
|
+
exports.isEnabled = isEnabled;
|
|
8
|
+
exports.shutdown = shutdown;
|
|
9
|
+
const sanitize_1 = require("./sanitize");
|
|
10
|
+
const queue_1 = require("./queue");
|
|
11
|
+
const session_1 = require("./session");
|
|
12
|
+
const DEFAULT_FLUSH_INTERVAL_MS = 5000;
|
|
13
|
+
const DEFAULT_MAX_QUEUE = 100;
|
|
14
|
+
const SEND_BATCH_SIZE = 25;
|
|
15
|
+
let queue = new queue_1.BoundedQueue(DEFAULT_MAX_QUEUE);
|
|
16
|
+
let flushTimer = null;
|
|
17
|
+
async function init(opts) {
|
|
18
|
+
const enabled = opts.initiallyEnabled !== undefined
|
|
19
|
+
? opts.initiallyEnabled
|
|
20
|
+
: await opts.configProvider.isEnabled();
|
|
21
|
+
let deviceId;
|
|
22
|
+
try {
|
|
23
|
+
deviceId = await opts.deviceIdProvider();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
deviceId = (0, session_1.newSessionId)();
|
|
27
|
+
}
|
|
28
|
+
(0, session_1.setSession)({
|
|
29
|
+
appName: opts.appName,
|
|
30
|
+
appVersion: opts.appVersion,
|
|
31
|
+
deviceId,
|
|
32
|
+
sessionId: (0, session_1.newSessionId)(),
|
|
33
|
+
enabled,
|
|
34
|
+
adapter: opts.adapter,
|
|
35
|
+
configProvider: opts.configProvider,
|
|
36
|
+
});
|
|
37
|
+
queue = new queue_1.BoundedQueue(opts.maxQueue ?? DEFAULT_MAX_QUEUE);
|
|
38
|
+
if (flushTimer)
|
|
39
|
+
clearInterval(flushTimer);
|
|
40
|
+
const ms = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
|
41
|
+
flushTimer = setInterval(() => {
|
|
42
|
+
void flush();
|
|
43
|
+
}, ms);
|
|
44
|
+
const t = flushTimer;
|
|
45
|
+
if (typeof t.unref === 'function')
|
|
46
|
+
t.unref();
|
|
47
|
+
}
|
|
48
|
+
function track(name, props = {}) {
|
|
49
|
+
const s = (0, session_1.getSession)();
|
|
50
|
+
if (!s || !s.enabled)
|
|
51
|
+
return;
|
|
52
|
+
if (!(0, sanitize_1.isValidEventName)(name))
|
|
53
|
+
return;
|
|
54
|
+
const params = (0, sanitize_1.sanitizeProps)(props);
|
|
55
|
+
queue.push({ name, params, ts: Date.now() });
|
|
56
|
+
}
|
|
57
|
+
async function flush() {
|
|
58
|
+
const s = (0, session_1.getSession)();
|
|
59
|
+
if (!s)
|
|
60
|
+
return;
|
|
61
|
+
const batch = queue.drain(SEND_BATCH_SIZE);
|
|
62
|
+
if (batch.length === 0)
|
|
63
|
+
return;
|
|
64
|
+
const enveloped = batch.map((e) => ({
|
|
65
|
+
name: e.name,
|
|
66
|
+
ts: e.ts,
|
|
67
|
+
params: {
|
|
68
|
+
...e.params,
|
|
69
|
+
app_name: s.appName,
|
|
70
|
+
app_version: s.appVersion,
|
|
71
|
+
session_id: s.sessionId,
|
|
72
|
+
engagement_time_msec: 1,
|
|
73
|
+
},
|
|
74
|
+
}));
|
|
75
|
+
try {
|
|
76
|
+
await s.adapter.send({ clientId: s.deviceId, events: enveloped });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Telemetry never throws into the host app.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function setEnabled(enabled) {
|
|
83
|
+
const s = (0, session_1.getSession)();
|
|
84
|
+
if (!s)
|
|
85
|
+
return;
|
|
86
|
+
s.enabled = enabled;
|
|
87
|
+
await s.configProvider.setEnabled(enabled);
|
|
88
|
+
}
|
|
89
|
+
function isEnabled() {
|
|
90
|
+
return (0, session_1.getSession)()?.enabled ?? false;
|
|
91
|
+
}
|
|
92
|
+
function shutdown() {
|
|
93
|
+
if (flushTimer)
|
|
94
|
+
clearInterval(flushTimer);
|
|
95
|
+
flushTimer = null;
|
|
96
|
+
(0, session_1.resetSession)();
|
|
97
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Adapter } from '../adapters/types';
|
|
2
|
+
export type AppName = 'cli' | 'extension' | 'docs';
|
|
3
|
+
export type EventName = 'cli_command_invoked' | 'cli_run_completed' | 'cli_error' | 'cli_telemetry_disabled' | 'ext_installed' | 'ext_audit_started' | 'ext_audit_completed' | 'ext_audit_error' | 'ext_settings_changed' | 'docs_section_view' | 'outbound_click';
|
|
4
|
+
export type EventParamValue = string | number | boolean;
|
|
5
|
+
export interface TelemetryEvent {
|
|
6
|
+
name: string;
|
|
7
|
+
params: Record<string, EventParamValue>;
|
|
8
|
+
ts: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ConfigProvider {
|
|
11
|
+
isEnabled(): Promise<boolean>;
|
|
12
|
+
setEnabled(enabled: boolean): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export type DeviceIdProvider = () => Promise<string>;
|
|
15
|
+
export interface InitOptions {
|
|
16
|
+
appName: AppName;
|
|
17
|
+
appVersion: string;
|
|
18
|
+
adapter: Adapter;
|
|
19
|
+
deviceIdProvider: DeviceIdProvider;
|
|
20
|
+
configProvider: ConfigProvider;
|
|
21
|
+
flushIntervalMs?: number;
|
|
22
|
+
maxQueue?: number;
|
|
23
|
+
/** Override the resolved enabled state. When omitted, falls back to configProvider.isEnabled(). */
|
|
24
|
+
initiallyEnabled?: boolean;
|
|
25
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { init, track, setEnabled, isEnabled, flush, shutdown } from './core/tracker';
|
|
2
|
+
export { bucketScore, bucketPageCount, bucketIssueCount, bucketDurationMs, type ScoreBucket, type PageCountBucket, type IssueCountBucket, type DurationBucket, } from './core/buckets';
|
|
3
|
+
export { sanitizeProps, errorClassName, isValidEventName } from './core/sanitize';
|
|
4
|
+
export type { AppName, EventName, EventParamValue, TelemetryEvent, ConfigProvider, DeviceIdProvider, InitOptions, } from './core/types';
|
|
5
|
+
export { noopAdapter } from './adapters/noop';
|
|
6
|
+
export { createGa4MpAdapter, type Ga4MpAdapterOptions } from './adapters/ga4-mp';
|
|
7
|
+
export type { Adapter, AdapterPayload } from './adapters/types';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Public API for @a14y/telemetry — runtime-agnostic surface only.
|
|
3
|
+
// Runtime helpers live behind subpath exports:
|
|
4
|
+
// import { createNodeRuntime } from '@a14y/telemetry/node';
|
|
5
|
+
// import { createChromeExtRuntime } from '@a14y/telemetry/chrome-ext';
|
|
6
|
+
// Splitting them out keeps the extension bundle from pulling in `fs`/`os`
|
|
7
|
+
// and the Node consumers from pulling in the chrome.* type surface.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.createGa4MpAdapter = exports.noopAdapter = exports.isValidEventName = exports.errorClassName = exports.sanitizeProps = exports.bucketDurationMs = exports.bucketIssueCount = exports.bucketPageCount = exports.bucketScore = exports.shutdown = exports.flush = exports.isEnabled = exports.setEnabled = exports.track = exports.init = void 0;
|
|
10
|
+
var tracker_1 = require("./core/tracker");
|
|
11
|
+
Object.defineProperty(exports, "init", { enumerable: true, get: function () { return tracker_1.init; } });
|
|
12
|
+
Object.defineProperty(exports, "track", { enumerable: true, get: function () { return tracker_1.track; } });
|
|
13
|
+
Object.defineProperty(exports, "setEnabled", { enumerable: true, get: function () { return tracker_1.setEnabled; } });
|
|
14
|
+
Object.defineProperty(exports, "isEnabled", { enumerable: true, get: function () { return tracker_1.isEnabled; } });
|
|
15
|
+
Object.defineProperty(exports, "flush", { enumerable: true, get: function () { return tracker_1.flush; } });
|
|
16
|
+
Object.defineProperty(exports, "shutdown", { enumerable: true, get: function () { return tracker_1.shutdown; } });
|
|
17
|
+
var buckets_1 = require("./core/buckets");
|
|
18
|
+
Object.defineProperty(exports, "bucketScore", { enumerable: true, get: function () { return buckets_1.bucketScore; } });
|
|
19
|
+
Object.defineProperty(exports, "bucketPageCount", { enumerable: true, get: function () { return buckets_1.bucketPageCount; } });
|
|
20
|
+
Object.defineProperty(exports, "bucketIssueCount", { enumerable: true, get: function () { return buckets_1.bucketIssueCount; } });
|
|
21
|
+
Object.defineProperty(exports, "bucketDurationMs", { enumerable: true, get: function () { return buckets_1.bucketDurationMs; } });
|
|
22
|
+
var sanitize_1 = require("./core/sanitize");
|
|
23
|
+
Object.defineProperty(exports, "sanitizeProps", { enumerable: true, get: function () { return sanitize_1.sanitizeProps; } });
|
|
24
|
+
Object.defineProperty(exports, "errorClassName", { enumerable: true, get: function () { return sanitize_1.errorClassName; } });
|
|
25
|
+
Object.defineProperty(exports, "isValidEventName", { enumerable: true, get: function () { return sanitize_1.isValidEventName; } });
|
|
26
|
+
var noop_1 = require("./adapters/noop");
|
|
27
|
+
Object.defineProperty(exports, "noopAdapter", { enumerable: true, get: function () { return noop_1.noopAdapter; } });
|
|
28
|
+
var ga4_mp_1 = require("./adapters/ga4-mp");
|
|
29
|
+
Object.defineProperty(exports, "createGa4MpAdapter", { enumerable: true, get: function () { return ga4_mp_1.createGa4MpAdapter; } });
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ConfigProvider, DeviceIdProvider } from '../core/types';
|
|
2
|
+
export interface ChromeExtRuntime {
|
|
3
|
+
deviceIdProvider: DeviceIdProvider;
|
|
4
|
+
configProvider: ConfigProvider;
|
|
5
|
+
isNoticeShown(): Promise<boolean>;
|
|
6
|
+
markNoticeShown(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export declare function createChromeExtRuntime(): ChromeExtRuntime;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createChromeExtRuntime = createChromeExtRuntime;
|
|
4
|
+
const STORAGE_KEY_DEVICE_ID = 'a14y:device-id';
|
|
5
|
+
const STORAGE_KEY_SETTINGS = 'a14y:settings';
|
|
6
|
+
function getStorage() {
|
|
7
|
+
const c = globalThis.chrome;
|
|
8
|
+
if (!c?.storage?.local) {
|
|
9
|
+
throw new Error('chrome.storage.local not available');
|
|
10
|
+
}
|
|
11
|
+
return c.storage.local;
|
|
12
|
+
}
|
|
13
|
+
function uuid() {
|
|
14
|
+
const g = globalThis;
|
|
15
|
+
if (g.crypto?.randomUUID)
|
|
16
|
+
return g.crypto.randomUUID();
|
|
17
|
+
const bytes = new Uint8Array(16);
|
|
18
|
+
if (g.crypto?.getRandomValues)
|
|
19
|
+
g.crypto.getRandomValues(bytes);
|
|
20
|
+
else
|
|
21
|
+
for (let i = 0; i < 16; i++)
|
|
22
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
23
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
24
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
25
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
26
|
+
return (hex.slice(0, 8) +
|
|
27
|
+
'-' +
|
|
28
|
+
hex.slice(8, 12) +
|
|
29
|
+
'-' +
|
|
30
|
+
hex.slice(12, 16) +
|
|
31
|
+
'-' +
|
|
32
|
+
hex.slice(16, 20) +
|
|
33
|
+
'-' +
|
|
34
|
+
hex.slice(20));
|
|
35
|
+
}
|
|
36
|
+
async function readSettings(storage) {
|
|
37
|
+
const got = await storage.get(STORAGE_KEY_SETTINGS);
|
|
38
|
+
const v = got[STORAGE_KEY_SETTINGS];
|
|
39
|
+
return v && typeof v === 'object' ? v : {};
|
|
40
|
+
}
|
|
41
|
+
async function writeSettings(storage, patch) {
|
|
42
|
+
const cur = await readSettings(storage);
|
|
43
|
+
await storage.set({ [STORAGE_KEY_SETTINGS]: { ...cur, ...patch } });
|
|
44
|
+
}
|
|
45
|
+
function createChromeExtRuntime() {
|
|
46
|
+
return {
|
|
47
|
+
async deviceIdProvider() {
|
|
48
|
+
const storage = getStorage();
|
|
49
|
+
const got = await storage.get(STORAGE_KEY_DEVICE_ID);
|
|
50
|
+
const existing = got[STORAGE_KEY_DEVICE_ID];
|
|
51
|
+
if (typeof existing === 'string' && existing.length > 0)
|
|
52
|
+
return existing;
|
|
53
|
+
const id = uuid();
|
|
54
|
+
await storage.set({ [STORAGE_KEY_DEVICE_ID]: id });
|
|
55
|
+
return id;
|
|
56
|
+
},
|
|
57
|
+
configProvider: {
|
|
58
|
+
async isEnabled() {
|
|
59
|
+
const storage = getStorage();
|
|
60
|
+
const settings = await readSettings(storage);
|
|
61
|
+
return settings.telemetryEnabled !== false;
|
|
62
|
+
},
|
|
63
|
+
async setEnabled(enabled) {
|
|
64
|
+
await writeSettings(getStorage(), { telemetryEnabled: enabled });
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
async isNoticeShown() {
|
|
68
|
+
const settings = await readSettings(getStorage());
|
|
69
|
+
return settings.telemetryNoticeShown === true;
|
|
70
|
+
},
|
|
71
|
+
async markNoticeShown() {
|
|
72
|
+
await writeSettings(getStorage(), { telemetryNoticeShown: true });
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ConfigProvider, DeviceIdProvider } from '../core/types';
|
|
2
|
+
export interface NodeRuntime {
|
|
3
|
+
deviceIdProvider: DeviceIdProvider;
|
|
4
|
+
configProvider: ConfigProvider;
|
|
5
|
+
isFirstRunNoticeShown(): Promise<boolean>;
|
|
6
|
+
markFirstRunNoticeShown(): Promise<void>;
|
|
7
|
+
/** True when the on-disk config is unavailable and we're using ephemeral memory. */
|
|
8
|
+
isEphemeral(): boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function createNodeRuntime(): NodeRuntime;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createNodeRuntime = createNodeRuntime;
|
|
7
|
+
const fs_1 = require("fs");
|
|
8
|
+
const os_1 = require("os");
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const crypto_1 = require("crypto");
|
|
11
|
+
function configDir() {
|
|
12
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
13
|
+
if (xdg && xdg.length > 0)
|
|
14
|
+
return path_1.default.join(xdg, 'a14y');
|
|
15
|
+
let home;
|
|
16
|
+
try {
|
|
17
|
+
home = (0, os_1.homedir)();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (!home)
|
|
23
|
+
return null;
|
|
24
|
+
return path_1.default.join(home, '.a14y');
|
|
25
|
+
}
|
|
26
|
+
async function readConfigFile(dir) {
|
|
27
|
+
const file = path_1.default.join(dir, 'config.json');
|
|
28
|
+
try {
|
|
29
|
+
const raw = await fs_1.promises.readFile(file, 'utf8');
|
|
30
|
+
const parsed = JSON.parse(raw);
|
|
31
|
+
if (parsed && typeof parsed === 'object')
|
|
32
|
+
return parsed;
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function writeConfigFile(dir, cfg) {
|
|
40
|
+
const file = path_1.default.join(dir, 'config.json');
|
|
41
|
+
const tmp = file + '.tmp';
|
|
42
|
+
await fs_1.promises.mkdir(dir, { recursive: true });
|
|
43
|
+
await fs_1.promises.writeFile(tmp, JSON.stringify(cfg, null, 2), 'utf8');
|
|
44
|
+
await fs_1.promises.rename(tmp, file);
|
|
45
|
+
}
|
|
46
|
+
function createNodeRuntime() {
|
|
47
|
+
const dir = configDir();
|
|
48
|
+
let ephemeral = null;
|
|
49
|
+
let forcedEphemeral = dir === null;
|
|
50
|
+
async function read() {
|
|
51
|
+
if (forcedEphemeral || dir === null) {
|
|
52
|
+
if (!ephemeral)
|
|
53
|
+
ephemeral = { deviceId: (0, crypto_1.randomUUID)(), telemetryEnabled: false };
|
|
54
|
+
return ephemeral;
|
|
55
|
+
}
|
|
56
|
+
return readConfigFile(dir);
|
|
57
|
+
}
|
|
58
|
+
async function update(updater) {
|
|
59
|
+
if (forcedEphemeral || dir === null) {
|
|
60
|
+
ephemeral = updater(ephemeral ?? {});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const cur = await readConfigFile(dir);
|
|
64
|
+
const next = updater(cur);
|
|
65
|
+
try {
|
|
66
|
+
await writeConfigFile(dir, next);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
forcedEphemeral = true;
|
|
70
|
+
ephemeral = { ...next, telemetryEnabled: false };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
async deviceIdProvider() {
|
|
75
|
+
const cfg = await read();
|
|
76
|
+
if (typeof cfg.deviceId === 'string' && cfg.deviceId.length > 0)
|
|
77
|
+
return cfg.deviceId;
|
|
78
|
+
const id = (0, crypto_1.randomUUID)();
|
|
79
|
+
await update((c) => ({ ...c, deviceId: id }));
|
|
80
|
+
return id;
|
|
81
|
+
},
|
|
82
|
+
configProvider: {
|
|
83
|
+
async isEnabled() {
|
|
84
|
+
if (forcedEphemeral)
|
|
85
|
+
return false;
|
|
86
|
+
const cfg = await read();
|
|
87
|
+
return cfg.telemetryEnabled !== false;
|
|
88
|
+
},
|
|
89
|
+
async setEnabled(enabled) {
|
|
90
|
+
await update((c) => ({ ...c, telemetryEnabled: enabled }));
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
async isFirstRunNoticeShown() {
|
|
94
|
+
const cfg = await read();
|
|
95
|
+
return cfg.firstRunNoticeShown === true;
|
|
96
|
+
},
|
|
97
|
+
async markFirstRunNoticeShown() {
|
|
98
|
+
await update((c) => ({ ...c, firstRunNoticeShown: true }));
|
|
99
|
+
},
|
|
100
|
+
isEphemeral() {
|
|
101
|
+
return forcedEphemeral;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function createGa4MpAdapter(opts) {
|
|
2
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
3
|
+
const host = opts.endpoint ?? 'https://www.google-analytics.com';
|
|
4
|
+
const path = opts.debug ? '/debug/mp/collect' : '/mp/collect';
|
|
5
|
+
return {
|
|
6
|
+
name: opts.debug ? 'ga4-mp-debug' : 'ga4-mp',
|
|
7
|
+
async send(payload) {
|
|
8
|
+
if (payload.events.length === 0)
|
|
9
|
+
return;
|
|
10
|
+
if (typeof fetchImpl !== 'function')
|
|
11
|
+
return;
|
|
12
|
+
const url = host +
|
|
13
|
+
path +
|
|
14
|
+
'?measurement_id=' +
|
|
15
|
+
encodeURIComponent(opts.measurementId) +
|
|
16
|
+
'&api_secret=' +
|
|
17
|
+
encodeURIComponent(opts.apiSecret);
|
|
18
|
+
const body = JSON.stringify({
|
|
19
|
+
client_id: payload.clientId,
|
|
20
|
+
events: payload.events.map((e) => ({ name: e.name, params: e.params })),
|
|
21
|
+
});
|
|
22
|
+
try {
|
|
23
|
+
await fetchImpl(url, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'content-type': 'application/json' },
|
|
26
|
+
body,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Network failures are swallowed.
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function bucketScore(n) {
|
|
2
|
+
if (!Number.isFinite(n) || n <= 25)
|
|
3
|
+
return '0-25';
|
|
4
|
+
if (n <= 50)
|
|
5
|
+
return '26-50';
|
|
6
|
+
if (n <= 75)
|
|
7
|
+
return '51-75';
|
|
8
|
+
return '76-100';
|
|
9
|
+
}
|
|
10
|
+
export function bucketPageCount(n) {
|
|
11
|
+
if (!Number.isFinite(n) || n <= 1)
|
|
12
|
+
return '1';
|
|
13
|
+
if (n <= 10)
|
|
14
|
+
return '2-10';
|
|
15
|
+
if (n <= 50)
|
|
16
|
+
return '11-50';
|
|
17
|
+
if (n <= 200)
|
|
18
|
+
return '51-200';
|
|
19
|
+
return '200+';
|
|
20
|
+
}
|
|
21
|
+
export function bucketIssueCount(n) {
|
|
22
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
23
|
+
return '0';
|
|
24
|
+
if (n <= 2)
|
|
25
|
+
return '1-2';
|
|
26
|
+
if (n <= 5)
|
|
27
|
+
return '3-5';
|
|
28
|
+
if (n <= 10)
|
|
29
|
+
return '6-10';
|
|
30
|
+
return '11+';
|
|
31
|
+
}
|
|
32
|
+
export function bucketDurationMs(ms) {
|
|
33
|
+
if (!Number.isFinite(ms) || ms < 5000)
|
|
34
|
+
return 'lt_5s';
|
|
35
|
+
if (ms < 30000)
|
|
36
|
+
return '5-30s';
|
|
37
|
+
if (ms < 120000)
|
|
38
|
+
return '30s-2m';
|
|
39
|
+
if (ms < 600000)
|
|
40
|
+
return '2-10m';
|
|
41
|
+
return 'gt_10m';
|
|
42
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Bounded FIFO that drops the oldest entry when full. */
|
|
2
|
+
export class BoundedQueue {
|
|
3
|
+
constructor(max) {
|
|
4
|
+
this.max = max;
|
|
5
|
+
this.buf = [];
|
|
6
|
+
}
|
|
7
|
+
push(item) {
|
|
8
|
+
if (this.buf.length >= this.max)
|
|
9
|
+
this.buf.shift();
|
|
10
|
+
this.buf.push(item);
|
|
11
|
+
}
|
|
12
|
+
drain(maxBatch) {
|
|
13
|
+
return this.buf.splice(0, maxBatch);
|
|
14
|
+
}
|
|
15
|
+
get length() {
|
|
16
|
+
return this.buf.length;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const PII_KEY_RE = /url|href|host|email|\bip\b/i;
|
|
2
|
+
const PATH_KEY_RE = /(^|_)path($|_)/i;
|
|
3
|
+
const GA4_EVENT_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_]{0,39}$/;
|
|
4
|
+
const MAX_PARAM_NAME = 40;
|
|
5
|
+
const MAX_STRING_VALUE = 100;
|
|
6
|
+
const MAX_PARAMS_PER_EVENT = 25;
|
|
7
|
+
const PATH_KEY_ALLOWLIST = new Set(['path_category']);
|
|
8
|
+
export function errorClassName(e) {
|
|
9
|
+
if (typeof e === 'object' &&
|
|
10
|
+
e !== null &&
|
|
11
|
+
typeof e.constructor?.name === 'string') {
|
|
12
|
+
return e.constructor.name;
|
|
13
|
+
}
|
|
14
|
+
return 'Error';
|
|
15
|
+
}
|
|
16
|
+
export function isValidEventName(name) {
|
|
17
|
+
return GA4_EVENT_NAME_RE.test(name);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Strip PII-shaped keys, coerce values to GA4-compatible primitives, enforce
|
|
21
|
+
* GA4 length limits. Drops anything it can't safely send.
|
|
22
|
+
*/
|
|
23
|
+
export function sanitizeProps(props) {
|
|
24
|
+
const out = {};
|
|
25
|
+
let count = 0;
|
|
26
|
+
for (const [key, raw] of Object.entries(props)) {
|
|
27
|
+
if (count >= MAX_PARAMS_PER_EVENT)
|
|
28
|
+
break;
|
|
29
|
+
if (raw === undefined || raw === null)
|
|
30
|
+
continue;
|
|
31
|
+
if (key.length === 0 || key.length > MAX_PARAM_NAME)
|
|
32
|
+
continue;
|
|
33
|
+
if (PII_KEY_RE.test(key))
|
|
34
|
+
continue;
|
|
35
|
+
if (PATH_KEY_RE.test(key) && !PATH_KEY_ALLOWLIST.has(key))
|
|
36
|
+
continue;
|
|
37
|
+
let value;
|
|
38
|
+
if (typeof raw === 'boolean' || typeof raw === 'number') {
|
|
39
|
+
if (typeof raw === 'number' && !Number.isFinite(raw))
|
|
40
|
+
continue;
|
|
41
|
+
value = raw;
|
|
42
|
+
}
|
|
43
|
+
else if (typeof raw === 'string') {
|
|
44
|
+
value = raw.slice(0, MAX_STRING_VALUE);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
out[key] = value;
|
|
50
|
+
count++;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
let session = null;
|
|
2
|
+
export function setSession(s) {
|
|
3
|
+
session = s;
|
|
4
|
+
}
|
|
5
|
+
export function getSession() {
|
|
6
|
+
return session;
|
|
7
|
+
}
|
|
8
|
+
export function resetSession() {
|
|
9
|
+
session = null;
|
|
10
|
+
}
|
|
11
|
+
/** 16-hex-char session id. Not a security boundary — uniqueness within a run is enough. */
|
|
12
|
+
export function newSessionId() {
|
|
13
|
+
const bytes = new Uint8Array(8);
|
|
14
|
+
const g = globalThis;
|
|
15
|
+
if (g.crypto?.getRandomValues) {
|
|
16
|
+
g.crypto.getRandomValues(bytes);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
for (let i = 0; i < 8; i++)
|
|
20
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
21
|
+
}
|
|
22
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
23
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { sanitizeProps, isValidEventName } from './sanitize';
|
|
2
|
+
import { BoundedQueue } from './queue';
|
|
3
|
+
import { setSession, getSession, resetSession, newSessionId } from './session';
|
|
4
|
+
const DEFAULT_FLUSH_INTERVAL_MS = 5000;
|
|
5
|
+
const DEFAULT_MAX_QUEUE = 100;
|
|
6
|
+
const SEND_BATCH_SIZE = 25;
|
|
7
|
+
let queue = new BoundedQueue(DEFAULT_MAX_QUEUE);
|
|
8
|
+
let flushTimer = null;
|
|
9
|
+
export async function init(opts) {
|
|
10
|
+
const enabled = opts.initiallyEnabled !== undefined
|
|
11
|
+
? opts.initiallyEnabled
|
|
12
|
+
: await opts.configProvider.isEnabled();
|
|
13
|
+
let deviceId;
|
|
14
|
+
try {
|
|
15
|
+
deviceId = await opts.deviceIdProvider();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
deviceId = newSessionId();
|
|
19
|
+
}
|
|
20
|
+
setSession({
|
|
21
|
+
appName: opts.appName,
|
|
22
|
+
appVersion: opts.appVersion,
|
|
23
|
+
deviceId,
|
|
24
|
+
sessionId: newSessionId(),
|
|
25
|
+
enabled,
|
|
26
|
+
adapter: opts.adapter,
|
|
27
|
+
configProvider: opts.configProvider,
|
|
28
|
+
});
|
|
29
|
+
queue = new BoundedQueue(opts.maxQueue ?? DEFAULT_MAX_QUEUE);
|
|
30
|
+
if (flushTimer)
|
|
31
|
+
clearInterval(flushTimer);
|
|
32
|
+
const ms = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
|
33
|
+
flushTimer = setInterval(() => {
|
|
34
|
+
void flush();
|
|
35
|
+
}, ms);
|
|
36
|
+
const t = flushTimer;
|
|
37
|
+
if (typeof t.unref === 'function')
|
|
38
|
+
t.unref();
|
|
39
|
+
}
|
|
40
|
+
export function track(name, props = {}) {
|
|
41
|
+
const s = getSession();
|
|
42
|
+
if (!s || !s.enabled)
|
|
43
|
+
return;
|
|
44
|
+
if (!isValidEventName(name))
|
|
45
|
+
return;
|
|
46
|
+
const params = sanitizeProps(props);
|
|
47
|
+
queue.push({ name, params, ts: Date.now() });
|
|
48
|
+
}
|
|
49
|
+
export async function flush() {
|
|
50
|
+
const s = getSession();
|
|
51
|
+
if (!s)
|
|
52
|
+
return;
|
|
53
|
+
const batch = queue.drain(SEND_BATCH_SIZE);
|
|
54
|
+
if (batch.length === 0)
|
|
55
|
+
return;
|
|
56
|
+
const enveloped = batch.map((e) => ({
|
|
57
|
+
name: e.name,
|
|
58
|
+
ts: e.ts,
|
|
59
|
+
params: {
|
|
60
|
+
...e.params,
|
|
61
|
+
app_name: s.appName,
|
|
62
|
+
app_version: s.appVersion,
|
|
63
|
+
session_id: s.sessionId,
|
|
64
|
+
engagement_time_msec: 1,
|
|
65
|
+
},
|
|
66
|
+
}));
|
|
67
|
+
try {
|
|
68
|
+
await s.adapter.send({ clientId: s.deviceId, events: enveloped });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Telemetry never throws into the host app.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export async function setEnabled(enabled) {
|
|
75
|
+
const s = getSession();
|
|
76
|
+
if (!s)
|
|
77
|
+
return;
|
|
78
|
+
s.enabled = enabled;
|
|
79
|
+
await s.configProvider.setEnabled(enabled);
|
|
80
|
+
}
|
|
81
|
+
export function isEnabled() {
|
|
82
|
+
return getSession()?.enabled ?? false;
|
|
83
|
+
}
|
|
84
|
+
export function shutdown() {
|
|
85
|
+
if (flushTimer)
|
|
86
|
+
clearInterval(flushTimer);
|
|
87
|
+
flushTimer = null;
|
|
88
|
+
resetSession();
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Public API for @a14y/telemetry — runtime-agnostic surface only.
|
|
2
|
+
// Runtime helpers live behind subpath exports:
|
|
3
|
+
// import { createNodeRuntime } from '@a14y/telemetry/node';
|
|
4
|
+
// import { createChromeExtRuntime } from '@a14y/telemetry/chrome-ext';
|
|
5
|
+
// Splitting them out keeps the extension bundle from pulling in `fs`/`os`
|
|
6
|
+
// and the Node consumers from pulling in the chrome.* type surface.
|
|
7
|
+
export { init, track, setEnabled, isEnabled, flush, shutdown } from './core/tracker';
|
|
8
|
+
export { bucketScore, bucketPageCount, bucketIssueCount, bucketDurationMs, } from './core/buckets';
|
|
9
|
+
export { sanitizeProps, errorClassName, isValidEventName } from './core/sanitize';
|
|
10
|
+
export { noopAdapter } from './adapters/noop';
|
|
11
|
+
export { createGa4MpAdapter } from './adapters/ga4-mp';
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const STORAGE_KEY_DEVICE_ID = 'a14y:device-id';
|
|
2
|
+
const STORAGE_KEY_SETTINGS = 'a14y:settings';
|
|
3
|
+
function getStorage() {
|
|
4
|
+
const c = globalThis.chrome;
|
|
5
|
+
if (!c?.storage?.local) {
|
|
6
|
+
throw new Error('chrome.storage.local not available');
|
|
7
|
+
}
|
|
8
|
+
return c.storage.local;
|
|
9
|
+
}
|
|
10
|
+
function uuid() {
|
|
11
|
+
const g = globalThis;
|
|
12
|
+
if (g.crypto?.randomUUID)
|
|
13
|
+
return g.crypto.randomUUID();
|
|
14
|
+
const bytes = new Uint8Array(16);
|
|
15
|
+
if (g.crypto?.getRandomValues)
|
|
16
|
+
g.crypto.getRandomValues(bytes);
|
|
17
|
+
else
|
|
18
|
+
for (let i = 0; i < 16; i++)
|
|
19
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
20
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
21
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
22
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
23
|
+
return (hex.slice(0, 8) +
|
|
24
|
+
'-' +
|
|
25
|
+
hex.slice(8, 12) +
|
|
26
|
+
'-' +
|
|
27
|
+
hex.slice(12, 16) +
|
|
28
|
+
'-' +
|
|
29
|
+
hex.slice(16, 20) +
|
|
30
|
+
'-' +
|
|
31
|
+
hex.slice(20));
|
|
32
|
+
}
|
|
33
|
+
async function readSettings(storage) {
|
|
34
|
+
const got = await storage.get(STORAGE_KEY_SETTINGS);
|
|
35
|
+
const v = got[STORAGE_KEY_SETTINGS];
|
|
36
|
+
return v && typeof v === 'object' ? v : {};
|
|
37
|
+
}
|
|
38
|
+
async function writeSettings(storage, patch) {
|
|
39
|
+
const cur = await readSettings(storage);
|
|
40
|
+
await storage.set({ [STORAGE_KEY_SETTINGS]: { ...cur, ...patch } });
|
|
41
|
+
}
|
|
42
|
+
export function createChromeExtRuntime() {
|
|
43
|
+
return {
|
|
44
|
+
async deviceIdProvider() {
|
|
45
|
+
const storage = getStorage();
|
|
46
|
+
const got = await storage.get(STORAGE_KEY_DEVICE_ID);
|
|
47
|
+
const existing = got[STORAGE_KEY_DEVICE_ID];
|
|
48
|
+
if (typeof existing === 'string' && existing.length > 0)
|
|
49
|
+
return existing;
|
|
50
|
+
const id = uuid();
|
|
51
|
+
await storage.set({ [STORAGE_KEY_DEVICE_ID]: id });
|
|
52
|
+
return id;
|
|
53
|
+
},
|
|
54
|
+
configProvider: {
|
|
55
|
+
async isEnabled() {
|
|
56
|
+
const storage = getStorage();
|
|
57
|
+
const settings = await readSettings(storage);
|
|
58
|
+
return settings.telemetryEnabled !== false;
|
|
59
|
+
},
|
|
60
|
+
async setEnabled(enabled) {
|
|
61
|
+
await writeSettings(getStorage(), { telemetryEnabled: enabled });
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
async isNoticeShown() {
|
|
65
|
+
const settings = await readSettings(getStorage());
|
|
66
|
+
return settings.telemetryNoticeShown === true;
|
|
67
|
+
},
|
|
68
|
+
async markNoticeShown() {
|
|
69
|
+
await writeSettings(getStorage(), { telemetryNoticeShown: true });
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
function configDir() {
|
|
6
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
7
|
+
if (xdg && xdg.length > 0)
|
|
8
|
+
return path.join(xdg, 'a14y');
|
|
9
|
+
let home;
|
|
10
|
+
try {
|
|
11
|
+
home = homedir();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (!home)
|
|
17
|
+
return null;
|
|
18
|
+
return path.join(home, '.a14y');
|
|
19
|
+
}
|
|
20
|
+
async function readConfigFile(dir) {
|
|
21
|
+
const file = path.join(dir, 'config.json');
|
|
22
|
+
try {
|
|
23
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
if (parsed && typeof parsed === 'object')
|
|
26
|
+
return parsed;
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function writeConfigFile(dir, cfg) {
|
|
34
|
+
const file = path.join(dir, 'config.json');
|
|
35
|
+
const tmp = file + '.tmp';
|
|
36
|
+
await fs.mkdir(dir, { recursive: true });
|
|
37
|
+
await fs.writeFile(tmp, JSON.stringify(cfg, null, 2), 'utf8');
|
|
38
|
+
await fs.rename(tmp, file);
|
|
39
|
+
}
|
|
40
|
+
export function createNodeRuntime() {
|
|
41
|
+
const dir = configDir();
|
|
42
|
+
let ephemeral = null;
|
|
43
|
+
let forcedEphemeral = dir === null;
|
|
44
|
+
async function read() {
|
|
45
|
+
if (forcedEphemeral || dir === null) {
|
|
46
|
+
if (!ephemeral)
|
|
47
|
+
ephemeral = { deviceId: randomUUID(), telemetryEnabled: false };
|
|
48
|
+
return ephemeral;
|
|
49
|
+
}
|
|
50
|
+
return readConfigFile(dir);
|
|
51
|
+
}
|
|
52
|
+
async function update(updater) {
|
|
53
|
+
if (forcedEphemeral || dir === null) {
|
|
54
|
+
ephemeral = updater(ephemeral ?? {});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const cur = await readConfigFile(dir);
|
|
58
|
+
const next = updater(cur);
|
|
59
|
+
try {
|
|
60
|
+
await writeConfigFile(dir, next);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
forcedEphemeral = true;
|
|
64
|
+
ephemeral = { ...next, telemetryEnabled: false };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
async deviceIdProvider() {
|
|
69
|
+
const cfg = await read();
|
|
70
|
+
if (typeof cfg.deviceId === 'string' && cfg.deviceId.length > 0)
|
|
71
|
+
return cfg.deviceId;
|
|
72
|
+
const id = randomUUID();
|
|
73
|
+
await update((c) => ({ ...c, deviceId: id }));
|
|
74
|
+
return id;
|
|
75
|
+
},
|
|
76
|
+
configProvider: {
|
|
77
|
+
async isEnabled() {
|
|
78
|
+
if (forcedEphemeral)
|
|
79
|
+
return false;
|
|
80
|
+
const cfg = await read();
|
|
81
|
+
return cfg.telemetryEnabled !== false;
|
|
82
|
+
},
|
|
83
|
+
async setEnabled(enabled) {
|
|
84
|
+
await update((c) => ({ ...c, telemetryEnabled: enabled }));
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
async isFirstRunNoticeShown() {
|
|
88
|
+
const cfg = await read();
|
|
89
|
+
return cfg.firstRunNoticeShown === true;
|
|
90
|
+
},
|
|
91
|
+
async markFirstRunNoticeShown() {
|
|
92
|
+
await update((c) => ({ ...c, firstRunNoticeShown: true }));
|
|
93
|
+
},
|
|
94
|
+
isEphemeral() {
|
|
95
|
+
return forcedEphemeral;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@a14y/telemetry",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Anonymous, opt-out telemetry shim shared by the a14y CLI and Chrome extension. Provider-agnostic core with a built-in GA4 Measurement Protocol adapter.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/timothyjordan/a14y.git",
|
|
9
|
+
"directory": "packages/telemetry"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://a14y.dev",
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"main": "dist/cjs/index.js",
|
|
16
|
+
"module": "dist/esm/index.js",
|
|
17
|
+
"types": "dist/cjs/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/cjs/index.d.ts",
|
|
21
|
+
"import": "./dist/esm/index.js",
|
|
22
|
+
"require": "./dist/cjs/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./node": {
|
|
25
|
+
"types": "./dist/cjs/runtime/node.d.ts",
|
|
26
|
+
"import": "./dist/esm/runtime/node.js",
|
|
27
|
+
"require": "./dist/cjs/runtime/node.js"
|
|
28
|
+
},
|
|
29
|
+
"./chrome-ext": {
|
|
30
|
+
"types": "./dist/cjs/runtime/chromeExt.d.ts",
|
|
31
|
+
"import": "./dist/esm/runtime/chromeExt.js",
|
|
32
|
+
"require": "./dist/cjs/runtime/chromeExt.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -p tsconfig.json && tsc -p tsconfig.esm.json",
|
|
42
|
+
"test": "vitest run"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^20.10.0",
|
|
46
|
+
"typescript": "^5.3.3",
|
|
47
|
+
"vitest": "^1.6.1"
|
|
48
|
+
}
|
|
49
|
+
}
|