- @bytelyst/auth-client: browser/RN-safe auth API wrapper (17 tests) - @bytelyst/telemetry-client: shared telemetry with configurable transport (11 tests) - @bytelyst/react-auth: add register, forgotPw, changePw, deleteAccount, token refresh (10 tests) - eslint.config: add missing browser globals
237 lines
5.6 KiB
TypeScript
237 lines
5.6 KiB
TypeScript
/**
|
|
* Browser/React Native-safe telemetry client for platform-service.
|
|
*
|
|
* Replaces hand-rolled telemetry clients in ChronoMind web, NomGap, and LysnrAI user-dashboard.
|
|
* No Node.js dependencies — uses globalThis.fetch and configurable storage.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* import { createTelemetryClient } from '@bytelyst/telemetry-client';
|
|
*
|
|
* const telemetry = createTelemetryClient({
|
|
* productId: 'chronomind',
|
|
* baseUrl: 'http://localhost:4003/api',
|
|
* platform: 'web',
|
|
* channel: 'pwa',
|
|
* transport: 'beacon',
|
|
* });
|
|
*
|
|
* telemetry.init();
|
|
* telemetry.trackEvent('info', 'timer', 'timer_created');
|
|
* ```
|
|
*/
|
|
|
|
import type {
|
|
TelemetryClient,
|
|
TelemetryClientConfig,
|
|
TelemetryEvent,
|
|
TelemetryStorage,
|
|
} from './types.js';
|
|
|
|
// ── UUID helper (browser + RN safe) ──────────────────────────────
|
|
|
|
function uuid(): string {
|
|
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
|
return globalThis.crypto.randomUUID();
|
|
}
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
const r = (Math.random() * 16) | 0;
|
|
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
});
|
|
}
|
|
|
|
// ── Noop storage ─────────────────────────────────────────────────
|
|
|
|
const noopStorage: TelemetryStorage = {
|
|
getItem: () => null,
|
|
setItem: () => {},
|
|
};
|
|
|
|
function getDefaultStorage(): TelemetryStorage {
|
|
if (
|
|
typeof globalThis.localStorage !== 'undefined' &&
|
|
typeof globalThis.localStorage?.getItem === 'function'
|
|
) {
|
|
return globalThis.localStorage;
|
|
}
|
|
return noopStorage;
|
|
}
|
|
|
|
// ── Factory ──────────────────────────────────────────────────────
|
|
|
|
export function createTelemetryClient(config: TelemetryClientConfig): TelemetryClient {
|
|
const {
|
|
productId,
|
|
baseUrl,
|
|
endpoint = '/telemetry/events',
|
|
platform,
|
|
channel,
|
|
transport = 'fetch',
|
|
maxQueue = 50,
|
|
flushIntervalMs = 30_000,
|
|
appVersion = '0.0.0',
|
|
buildNumber = '0',
|
|
releaseChannel = 'dev',
|
|
osFamily = 'other',
|
|
osVersion = '',
|
|
} = config;
|
|
|
|
const storage = config.storage ?? getDefaultStorage();
|
|
const INSTALL_KEY = `${productId}_telemetry_install_id`;
|
|
|
|
let queue: TelemetryEvent[] = [];
|
|
let sessionId = '';
|
|
let installId = '';
|
|
let flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
function getInstallId(): string {
|
|
if (installId) return installId;
|
|
const stored = storage.getItem(INSTALL_KEY);
|
|
if (stored) {
|
|
installId = stored;
|
|
return installId;
|
|
}
|
|
installId = uuid();
|
|
storage.setItem(INSTALL_KEY, installId);
|
|
return installId;
|
|
}
|
|
|
|
function getSessionId(): string {
|
|
return sessionId;
|
|
}
|
|
|
|
function flushViaBeacon(): void {
|
|
if (queue.length === 0) return;
|
|
const events = [...queue];
|
|
queue = [];
|
|
|
|
const body = JSON.stringify({ productId, events });
|
|
const url = `${baseUrl}${endpoint}`;
|
|
|
|
try {
|
|
const sent = typeof navigator?.sendBeacon === 'function' && navigator.sendBeacon(url, body);
|
|
if (!sent) {
|
|
// Fallback to fetch
|
|
globalThis
|
|
.fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-product-id': productId,
|
|
'x-request-id': uuid(),
|
|
},
|
|
body,
|
|
keepalive: true,
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
} catch {
|
|
// Silently ignore telemetry failures
|
|
}
|
|
}
|
|
|
|
function flushViaFetch(): void {
|
|
if (queue.length === 0) return;
|
|
const events = [...queue];
|
|
queue = [];
|
|
|
|
const body = JSON.stringify({ productId, events });
|
|
const url = `${baseUrl}${endpoint}`;
|
|
|
|
globalThis
|
|
.fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-product-id': productId,
|
|
'x-request-id': uuid(),
|
|
},
|
|
body,
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function flush(): void {
|
|
if (transport === 'beacon') {
|
|
flushViaBeacon();
|
|
} else {
|
|
flushViaFetch();
|
|
}
|
|
}
|
|
|
|
function trackEvent(
|
|
eventType: string,
|
|
module: string,
|
|
eventName: string,
|
|
extra?: {
|
|
feature?: string;
|
|
message?: string;
|
|
tags?: Record<string, string>;
|
|
metrics?: Record<string, number>;
|
|
userId?: string;
|
|
}
|
|
): void {
|
|
const event: TelemetryEvent = {
|
|
id: uuid(),
|
|
productId,
|
|
anonymousInstallId: getInstallId(),
|
|
sessionId,
|
|
platform,
|
|
channel,
|
|
osFamily,
|
|
osVersion,
|
|
appVersion,
|
|
buildNumber,
|
|
releaseChannel,
|
|
eventType,
|
|
module,
|
|
eventName,
|
|
...extra,
|
|
occurredAt: new Date().toISOString(),
|
|
};
|
|
|
|
queue.push(event);
|
|
|
|
if (queue.length >= maxQueue) {
|
|
flush();
|
|
}
|
|
}
|
|
|
|
function init(): void {
|
|
sessionId = uuid();
|
|
getInstallId();
|
|
|
|
// Auto-flush on visibility change (web only)
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'hidden') {
|
|
flush();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Periodic flush
|
|
if (flushTimer) clearInterval(flushTimer);
|
|
flushTimer = setInterval(flush, flushIntervalMs);
|
|
|
|
trackEvent('info', 'app_lifecycle', 'session_started');
|
|
}
|
|
|
|
function shutdown(): void {
|
|
flush();
|
|
if (flushTimer) {
|
|
clearInterval(flushTimer);
|
|
flushTimer = null;
|
|
}
|
|
}
|
|
|
|
return {
|
|
init,
|
|
trackEvent,
|
|
flush,
|
|
shutdown,
|
|
getInstallId,
|
|
getSessionId,
|
|
};
|
|
}
|