refactor(dashboards): wire @bytelyst/telemetry-client into admin-web + tracker-web, add onInit + baseUrl to react-auth
This commit is contained in:
parent
b400c76c0a
commit
da165a589a
@ -34,6 +34,7 @@
|
|||||||
"@bytelyst/extraction": "workspace:*",
|
"@bytelyst/extraction": "workspace:*",
|
||||||
"@bytelyst/logger": "workspace:*",
|
"@bytelyst/logger": "workspace:*",
|
||||||
"@bytelyst/react-auth": "workspace:*",
|
"@bytelyst/react-auth": "workspace:*",
|
||||||
|
"@bytelyst/telemetry-client": "workspace:*",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
|||||||
@ -1,72 +1,34 @@
|
|||||||
/**
|
/**
|
||||||
* Client-side self-telemetry for the admin dashboard.
|
* Client-side self-telemetry for the admin dashboard.
|
||||||
*
|
*
|
||||||
* Tracks admin page views, filter usage, and interaction events.
|
* Delegates to @bytelyst/telemetry-client shared package.
|
||||||
* Sends to platform-service via the admin dashboard's /api/telemetry/admin-ingest
|
* Sends to platform-service via /api/telemetry/admin-ingest proxy.
|
||||||
* proxy (separate from the admin telemetry query route at /api/telemetry).
|
|
||||||
*
|
*
|
||||||
* Privacy: No PII. Only page paths, action names, and timing metrics.
|
* Privacy: No PII. Only page paths, action names, and timing metrics.
|
||||||
* See docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md
|
* See docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Product ID resolved from env var set by the deploying product.
|
import { createTelemetryClient, type TelemetryClient } from '@bytelyst/telemetry-client';
|
||||||
|
|
||||||
const PRODUCT_ID = process.env.NEXT_PUBLIC_PRODUCT_ID || 'unknown';
|
const PRODUCT_ID = process.env.NEXT_PUBLIC_PRODUCT_ID || 'unknown';
|
||||||
const PLATFORM = 'web';
|
|
||||||
const OS_FAMILY = 'other'; // Zod OsFamilyEnum doesn't include 'web'; use 'other'
|
|
||||||
const CHANNEL = 'web_app';
|
|
||||||
const MAX_QUEUE = 50;
|
|
||||||
const FLUSH_INTERVAL_MS = 30_000;
|
|
||||||
|
|
||||||
interface TelemetryEvent {
|
let _client: TelemetryClient | null = null;
|
||||||
id: string;
|
|
||||||
productId: string;
|
|
||||||
userId?: string;
|
|
||||||
anonymousInstallId: string;
|
|
||||||
sessionId: string;
|
|
||||||
platform: string;
|
|
||||||
channel: string;
|
|
||||||
osFamily: string;
|
|
||||||
osVersion: string;
|
|
||||||
appVersion: string;
|
|
||||||
buildNumber: string;
|
|
||||||
releaseChannel: string;
|
|
||||||
eventType: string;
|
|
||||||
module: string;
|
|
||||||
eventName: string;
|
|
||||||
feature?: string;
|
|
||||||
message?: string;
|
|
||||||
tags?: Record<string, string>;
|
|
||||||
metrics?: Record<string, number>;
|
|
||||||
occurredAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let queue: TelemetryEvent[] = [];
|
function getClient(): TelemetryClient {
|
||||||
let sessionId = crypto.randomUUID();
|
if (!_client) {
|
||||||
let installId = '';
|
_client = createTelemetryClient({
|
||||||
let flushTimer: ReturnType<typeof setInterval> | null = null;
|
productId: PRODUCT_ID,
|
||||||
|
baseUrl: '',
|
||||||
function getInstallId(): string {
|
endpoint: '/api/telemetry/admin-ingest',
|
||||||
if (installId) return installId;
|
platform: 'web',
|
||||||
try {
|
channel: 'web_app',
|
||||||
const stored = localStorage.getItem(`${PRODUCT_ID}_admin_telemetry_install_id`);
|
transport: 'beacon',
|
||||||
if (stored) {
|
appVersion: '0.0.0',
|
||||||
installId = stored;
|
buildNumber: '0',
|
||||||
return installId;
|
releaseChannel: 'beta',
|
||||||
}
|
});
|
||||||
installId = crypto.randomUUID();
|
|
||||||
localStorage.setItem(`${PRODUCT_ID}_admin_telemetry_install_id`, installId);
|
|
||||||
} catch {
|
|
||||||
installId = crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return installId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserAgent(): string {
|
|
||||||
try {
|
|
||||||
return navigator.userAgent;
|
|
||||||
} catch {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
}
|
||||||
|
return _client;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trackEvent(
|
export function trackEvent(
|
||||||
@ -81,34 +43,7 @@ export function trackEvent(
|
|||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
getClient().trackEvent(eventType, module, eventName, options);
|
||||||
const event: TelemetryEvent = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
productId: PRODUCT_ID,
|
|
||||||
anonymousInstallId: getInstallId(),
|
|
||||||
sessionId,
|
|
||||||
platform: PLATFORM,
|
|
||||||
channel: CHANNEL,
|
|
||||||
osFamily: OS_FAMILY,
|
|
||||||
osVersion: getUserAgent().substring(0, 128),
|
|
||||||
appVersion: '0.0.0',
|
|
||||||
buildNumber: '0',
|
|
||||||
releaseChannel: 'beta',
|
|
||||||
eventType,
|
|
||||||
module,
|
|
||||||
eventName,
|
|
||||||
occurredAt: new Date().toISOString(),
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
|
|
||||||
queue.push(event);
|
|
||||||
if (queue.length > MAX_QUEUE) {
|
|
||||||
queue = queue.slice(-MAX_QUEUE);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queue.length >= 10) {
|
|
||||||
flush();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trackPageView(path: string): void {
|
export function trackPageView(path: string): void {
|
||||||
@ -118,44 +53,11 @@ export function trackPageView(path: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function flush(): void {
|
export function flush(): void {
|
||||||
if (typeof window === 'undefined' || queue.length === 0) return;
|
if (typeof window === 'undefined') return;
|
||||||
|
getClient().flush();
|
||||||
const events = [...queue];
|
|
||||||
queue = [];
|
|
||||||
|
|
||||||
// Use sendBeacon for reliability (works during page unload)
|
|
||||||
const body = JSON.stringify({ productId: PRODUCT_ID, events });
|
|
||||||
try {
|
|
||||||
const sent = navigator.sendBeacon('/api/telemetry/admin-ingest', body);
|
|
||||||
if (!sent) {
|
|
||||||
// Fallback to fetch
|
|
||||||
fetch('/api/telemetry/admin-ingest', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body,
|
|
||||||
keepalive: true,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Best effort
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initTelemetry(): void {
|
export function initTelemetry(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
getClient().init();
|
||||||
sessionId = crypto.randomUUID();
|
|
||||||
|
|
||||||
// Flush on page unload
|
|
||||||
window.addEventListener('visibilitychange', () => {
|
|
||||||
if (document.visibilityState === 'hidden') {
|
|
||||||
flush();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Periodic flush
|
|
||||||
if (flushTimer) clearInterval(flushTimer);
|
|
||||||
flushTimer = setInterval(flush, FLUSH_INTERVAL_MS);
|
|
||||||
|
|
||||||
trackEvent('info', 'app_lifecycle', 'session_started');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
"@bytelyst/api-client": "workspace:*",
|
"@bytelyst/api-client": "workspace:*",
|
||||||
"@bytelyst/config": "workspace:*",
|
"@bytelyst/config": "workspace:*",
|
||||||
"@bytelyst/errors": "workspace:*",
|
"@bytelyst/errors": "workspace:*",
|
||||||
|
"@bytelyst/telemetry-client": "workspace:*",
|
||||||
"@bytelyst/logger": "workspace:*",
|
"@bytelyst/logger": "workspace:*",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
|||||||
@ -1,72 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* Client-side telemetry for the tracker dashboard.
|
* Client-side telemetry for the tracker dashboard.
|
||||||
*
|
*
|
||||||
* Sends lightweight page-view and interaction events to platform-service
|
* Delegates to @bytelyst/telemetry-client shared package.
|
||||||
* via the tracker dashboard's /api/telemetry/ingest proxy. Runs in the browser only.
|
* Sends events via /api/telemetry/ingest proxy.
|
||||||
*
|
*
|
||||||
* Privacy: No PII. Only page paths, action names, and timing metrics.
|
* Privacy: No PII. Only page paths, action names, and timing metrics.
|
||||||
* See docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md
|
* See docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createTelemetryClient, type TelemetryClient } from '@bytelyst/telemetry-client';
|
||||||
|
|
||||||
// Product ID resolved from env var set by the deploying product.
|
// Product ID resolved from env var set by the deploying product.
|
||||||
// Each product sets NEXT_PUBLIC_PRODUCT_ID in its .env (e.g. 'lysnrai', 'chronomind', 'nomgap').
|
// Each product sets NEXT_PUBLIC_PRODUCT_ID in its .env (e.g. 'lysnrai', 'chronomind', 'nomgap').
|
||||||
const PRODUCT_ID = process.env.NEXT_PUBLIC_PRODUCT_ID || 'unknown';
|
const PRODUCT_ID = process.env.NEXT_PUBLIC_PRODUCT_ID || 'unknown';
|
||||||
const PLATFORM = 'web';
|
|
||||||
const OS_FAMILY = 'other'; // Zod OsFamilyEnum doesn't include 'web'; use 'other'
|
|
||||||
const CHANNEL = 'web_app';
|
|
||||||
const MAX_QUEUE = 50;
|
|
||||||
const FLUSH_INTERVAL_MS = 30_000;
|
|
||||||
|
|
||||||
interface TelemetryEvent {
|
let _client: TelemetryClient | null = null;
|
||||||
id: string;
|
|
||||||
productId: string;
|
|
||||||
userId?: string;
|
|
||||||
anonymousInstallId: string;
|
|
||||||
sessionId: string;
|
|
||||||
platform: string;
|
|
||||||
channel: string;
|
|
||||||
osFamily: string;
|
|
||||||
osVersion: string;
|
|
||||||
appVersion: string;
|
|
||||||
buildNumber: string;
|
|
||||||
releaseChannel: string;
|
|
||||||
eventType: string;
|
|
||||||
module: string;
|
|
||||||
eventName: string;
|
|
||||||
feature?: string;
|
|
||||||
message?: string;
|
|
||||||
tags?: Record<string, string>;
|
|
||||||
metrics?: Record<string, number>;
|
|
||||||
occurredAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let queue: TelemetryEvent[] = [];
|
function getClient(): TelemetryClient {
|
||||||
let sessionId = crypto.randomUUID();
|
if (!_client) {
|
||||||
let installId = '';
|
_client = createTelemetryClient({
|
||||||
let flushTimer: ReturnType<typeof setInterval> | null = null;
|
productId: PRODUCT_ID,
|
||||||
|
baseUrl: '',
|
||||||
function getInstallId(): string {
|
endpoint: '/api/telemetry/ingest',
|
||||||
if (installId) return installId;
|
platform: 'web',
|
||||||
try {
|
channel: 'web_app',
|
||||||
const stored = localStorage.getItem(`${PRODUCT_ID}_telemetry_install_id`);
|
transport: 'beacon',
|
||||||
if (stored) {
|
appVersion: '0.0.0',
|
||||||
installId = stored;
|
buildNumber: '0',
|
||||||
return installId;
|
releaseChannel: 'beta',
|
||||||
}
|
});
|
||||||
installId = crypto.randomUUID();
|
|
||||||
localStorage.setItem(`${PRODUCT_ID}_telemetry_install_id`, installId);
|
|
||||||
} catch {
|
|
||||||
installId = crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return installId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserAgent(): string {
|
|
||||||
try {
|
|
||||||
return navigator.userAgent;
|
|
||||||
} catch {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
}
|
||||||
|
return _client;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trackEvent(
|
export function trackEvent(
|
||||||
@ -81,34 +45,7 @@ export function trackEvent(
|
|||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
getClient().trackEvent(eventType, module, eventName, options);
|
||||||
const event: TelemetryEvent = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
productId: PRODUCT_ID,
|
|
||||||
anonymousInstallId: getInstallId(),
|
|
||||||
sessionId,
|
|
||||||
platform: PLATFORM,
|
|
||||||
channel: CHANNEL,
|
|
||||||
osFamily: OS_FAMILY,
|
|
||||||
osVersion: getUserAgent().substring(0, 128),
|
|
||||||
appVersion: '0.0.0',
|
|
||||||
buildNumber: '0',
|
|
||||||
releaseChannel: 'beta',
|
|
||||||
eventType,
|
|
||||||
module,
|
|
||||||
eventName,
|
|
||||||
occurredAt: new Date().toISOString(),
|
|
||||||
...options,
|
|
||||||
};
|
|
||||||
|
|
||||||
queue.push(event);
|
|
||||||
if (queue.length > MAX_QUEUE) {
|
|
||||||
queue = queue.slice(-MAX_QUEUE);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queue.length >= 10) {
|
|
||||||
flush();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trackPageView(path: string): void {
|
export function trackPageView(path: string): void {
|
||||||
@ -118,44 +55,11 @@ export function trackPageView(path: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function flush(): void {
|
export function flush(): void {
|
||||||
if (typeof window === 'undefined' || queue.length === 0) return;
|
if (typeof window === 'undefined') return;
|
||||||
|
getClient().flush();
|
||||||
const events = [...queue];
|
|
||||||
queue = [];
|
|
||||||
|
|
||||||
// Use sendBeacon for reliability (works during page unload)
|
|
||||||
const body = JSON.stringify({ productId: PRODUCT_ID, events });
|
|
||||||
try {
|
|
||||||
const sent = navigator.sendBeacon('/api/telemetry/ingest', body);
|
|
||||||
if (!sent) {
|
|
||||||
// Fallback to fetch
|
|
||||||
fetch('/api/telemetry/ingest', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body,
|
|
||||||
keepalive: true,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Best effort
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initTelemetry(): void {
|
export function initTelemetry(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
getClient().init();
|
||||||
sessionId = crypto.randomUUID();
|
|
||||||
|
|
||||||
// Flush on page unload
|
|
||||||
window.addEventListener('visibilitychange', () => {
|
|
||||||
if (document.visibilityState === 'hidden') {
|
|
||||||
flush();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Periodic flush
|
|
||||||
if (flushTimer) clearInterval(flushTimer);
|
|
||||||
flushTimer = setInterval(flush, FLUSH_INTERVAL_MS);
|
|
||||||
|
|
||||||
trackEvent('info', 'app_lifecycle', 'session_started');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import type { AuthConfig, AuthContextValue, BaseUser } from './types.js';
|
|||||||
*/
|
*/
|
||||||
export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: AuthConfig<TUser>) {
|
export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: AuthConfig<TUser>) {
|
||||||
const {
|
const {
|
||||||
|
baseUrl: configBaseUrl = '/api',
|
||||||
storagePrefix,
|
storagePrefix,
|
||||||
loginEndpoint,
|
loginEndpoint,
|
||||||
registerEndpoint,
|
registerEndpoint,
|
||||||
@ -48,6 +49,7 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
|
|||||||
refreshIntervalMs = 45 * 60 * 1000,
|
refreshIntervalMs = 45 * 60 * 1000,
|
||||||
mapLoginResponse,
|
mapLoginResponse,
|
||||||
onLoginFallback,
|
onLoginFallback,
|
||||||
|
onInit,
|
||||||
onLogout,
|
onLogout,
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
@ -80,14 +82,24 @@ export function createAuthProvider<TUser extends BaseUser = BaseUser>(config: Au
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AuthProvider({ children }: { children: ReactNode }) {
|
function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<TUser | null>(getStoredUser);
|
const [user, setUser] = useState<TUser | null>(() => {
|
||||||
|
// Allow onInit to provide an initial session (e.g. from SSO cookies)
|
||||||
|
if (onInit) {
|
||||||
|
const initResult = onInit();
|
||||||
|
if (initResult) {
|
||||||
|
saveSession(initResult.user, initResult.accessToken, initResult.refreshToken);
|
||||||
|
return initResult.user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getStoredUser();
|
||||||
|
});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
const api = createApiClient({
|
const api = createApiClient({
|
||||||
baseUrl: '/api',
|
baseUrl: configBaseUrl,
|
||||||
getToken: () => (typeof window !== 'undefined' ? localStorage.getItem(TOKEN_KEY) : null),
|
getToken: () => (typeof window !== 'undefined' ? localStorage.getItem(TOKEN_KEY) : null),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,8 @@ export interface LoginResult<TUser extends BaseUser = BaseUser> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthConfig<TUser extends BaseUser = BaseUser> {
|
export interface AuthConfig<TUser extends BaseUser = BaseUser> {
|
||||||
|
/** Base URL for auth API calls. Default: '/api'. */
|
||||||
|
baseUrl?: string;
|
||||||
storagePrefix: string;
|
storagePrefix: string;
|
||||||
loginEndpoint: string;
|
loginEndpoint: string;
|
||||||
registerEndpoint?: string;
|
registerEndpoint?: string;
|
||||||
@ -42,5 +44,7 @@ export interface AuthConfig<TUser extends BaseUser = BaseUser> {
|
|||||||
password: string,
|
password: string,
|
||||||
error: string
|
error: string
|
||||||
) => Promise<LoginResult<TUser> | null>;
|
) => Promise<LoginResult<TUser> | null>;
|
||||||
|
/** Called once on mount to provide an initial session (e.g. from SSO cookies). Return null to fall through to localStorage. */
|
||||||
|
onInit?: () => LoginResult<TUser> | null;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@ -101,6 +101,9 @@ importers:
|
|||||||
'@bytelyst/react-auth':
|
'@bytelyst/react-auth':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/react-auth
|
version: link:../../packages/react-auth
|
||||||
|
'@bytelyst/telemetry-client':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/telemetry-client
|
||||||
'@radix-ui/react-slider':
|
'@radix-ui/react-slider':
|
||||||
specifier: ^1.3.6
|
specifier: ^1.3.6
|
||||||
version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@ -225,6 +228,9 @@ importers:
|
|||||||
'@bytelyst/logger':
|
'@bytelyst/logger':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/logger
|
version: link:../../packages/logger
|
||||||
|
'@bytelyst/telemetry-client':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/telemetry-client
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user