mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
714 lines
25 KiB
TypeScript
714 lines
25 KiB
TypeScript
/**
|
|
* Analytics & Sentry service
|
|
*
|
|
* Initializes Sentry for error reporting and OpenPanel for privacy-limited
|
|
* usage tracking. Both are gated on user analytics consent.
|
|
*
|
|
* Sentry privacy guarantees enforced in `beforeSend`:
|
|
* - No breadcrumbs, requests, extras, or arbitrary contexts (only OS /
|
|
* browser / device metadata kept)
|
|
* - No frame-level locals or source-context snippets
|
|
* - No PII — `user` is reduced to a stable account id (or omitted)
|
|
* - `sendDefaultPii: false` (no IP, no cookies)
|
|
* - All breadcrumb-producing integrations disabled
|
|
*
|
|
* OpenPanel privacy guarantees:
|
|
* - Only page views and feature-engagement events from the allowlist are sent
|
|
* - No user content, messages, credentials, or PII is ever included
|
|
*/
|
|
import * as Sentry from '@sentry/react';
|
|
|
|
import { getCoreStateSnapshot } from '../lib/coreState/store';
|
|
import {
|
|
APP_BINARY_VERSION,
|
|
APP_ENVIRONMENT,
|
|
APP_VERSION,
|
|
BUILD_SHA,
|
|
CORE_CARGO_VERSION,
|
|
GA_MEASUREMENT_ID,
|
|
IS_DEV,
|
|
OPENPANEL_API_URL,
|
|
OPENPANEL_CLIENT_ID,
|
|
SENTRY_DSN,
|
|
SENTRY_RELEASE,
|
|
SENTRY_SMOKE_TEST,
|
|
TAURI_CARGO_VERSION,
|
|
} from '../utils/config';
|
|
import { CoreRpcError } from './coreRpcClient';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Google Analytics 4 typings — raw gtag.js API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type GtagCommand = 'config' | 'event' | 'set' | 'js';
|
|
interface GtagFn {
|
|
(...args: [GtagCommand, ...unknown[]]): void;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
dataLayer: unknown[];
|
|
gtag: GtagFn;
|
|
}
|
|
}
|
|
|
|
const OPENPANEL_TRACK_URL = `${OPENPANEL_API_URL}/track`;
|
|
const MAX_PENDING_ANALYTICS_EVENTS = 20;
|
|
const INTERACTIVE_CLICK_SELECTOR =
|
|
'button,a,[role="button"],summary,[data-track],[data-analytics-id],[data-testid],[data-walkthrough]';
|
|
const CONTROL_CHANGE_SELECTOR =
|
|
'select,input[type="checkbox"],input[type="radio"],input[type="range"],[role="switch"],[role="checkbox"],[role="radio"]';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Module-level state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let gaInitialized = false;
|
|
let opInitialized = false;
|
|
let analyticsConsentSynced = false;
|
|
let uiInteractionTrackingStarted = false;
|
|
|
|
type AnalyticsParams = Record<string, string | number | boolean>;
|
|
|
|
interface PendingAnalyticsEvent {
|
|
type: 'event' | 'page_view';
|
|
name: string;
|
|
params?: AnalyticsParams;
|
|
}
|
|
|
|
const pendingAnalyticsEvents: PendingAnalyticsEvent[] = [];
|
|
|
|
/**
|
|
* Shadow of the user's analytics consent state. Kept in sync by
|
|
* `syncAnalyticsConsent`. Default: `false` (deny until explicitly allowed).
|
|
*/
|
|
let analyticsEnabled = false;
|
|
|
|
/**
|
|
* Allowlist of event names that may be sent to OpenPanel.
|
|
*
|
|
* Keeping an explicit allowlist prevents accidentally forwarding internal
|
|
* debug names or future ad-hoc calls that could carry sensitive information.
|
|
* Any `trackEvent` call with a name not in this set is dropped and a warning
|
|
* is logged.
|
|
*/
|
|
export const ALLOWED_EVENTS = new Set([
|
|
'app_open',
|
|
'onboarding_start',
|
|
'onboarding_step_complete',
|
|
'onboarding_complete',
|
|
'account_connect_start',
|
|
'account_connect_success',
|
|
'chat_message_sent',
|
|
'skill_install',
|
|
'skill_uninstall',
|
|
'tab_bar_change',
|
|
'tauri_browser_click',
|
|
'ui_click',
|
|
'ui_control_change',
|
|
'ui_form_submit',
|
|
]);
|
|
|
|
/** Check if the current user has opted into analytics. */
|
|
export function isAnalyticsEnabled(): boolean {
|
|
return getCoreStateSnapshot().snapshot.analyticsEnabled;
|
|
}
|
|
|
|
/**
|
|
* Cross-realm-safe check for a `CoreRpcError` with `kind === 'timeout'`.
|
|
* `instanceof` can fail across module scopes (test harness, dynamic import,
|
|
* Vitest module isolation), so also accept a duck-typed match on `name`
|
|
* and `kind`. Used by the Sentry `beforeSend` filter to drop the
|
|
* OPENHUMAN-REACT-15/11/10/12/Z/Y family at the source.
|
|
*/
|
|
function isCoreRpcTimeoutError(err: unknown): boolean {
|
|
if (err instanceof CoreRpcError) return err.kind === 'timeout';
|
|
if (typeof err !== 'object' || err === null) return false;
|
|
const candidate = err as { name?: unknown; kind?: unknown };
|
|
return candidate.name === 'CoreRpcError' && candidate.kind === 'timeout';
|
|
}
|
|
|
|
export function initSentry(): void {
|
|
if (!SENTRY_DSN) return;
|
|
|
|
Sentry.init({
|
|
dsn: SENTRY_DSN,
|
|
environment: APP_ENVIRONMENT,
|
|
// Canonical release tag shared with the Tauri shell (see
|
|
// `app/src-tauri/src/lib.rs::build_sentry_release_tag`) and the Vite
|
|
// source-map upload (see `@sentry/vite-plugin` in app/vite.config.ts)
|
|
// so events from every surface group under the same release.
|
|
release: SENTRY_RELEASE,
|
|
enabled: !IS_DEV,
|
|
|
|
// Privacy: disable EVERYTHING that could leak sensitive state.
|
|
replaysSessionSampleRate: 0,
|
|
replaysOnErrorSampleRate: 0,
|
|
tracesSampleRate: 0,
|
|
defaultIntegrations: false,
|
|
integrations: [
|
|
Sentry.functionToStringIntegration(),
|
|
Sentry.linkedErrorsIntegration(),
|
|
Sentry.dedupeIntegration(),
|
|
Sentry.browserApiErrorsIntegration(),
|
|
Sentry.globalHandlersIntegration(),
|
|
// #1403: production events were missing `os.name` / `browser.name` /
|
|
// `device.family` because Sentry derives those by parsing the
|
|
// User-Agent header server-side, and `defaultIntegrations: false`
|
|
// (above) drops the integration that attaches `event.request.headers`.
|
|
// Re-include it explicitly so platform context comes back. `beforeSend`
|
|
// narrows what survives from the request envelope (headers only, UA
|
|
// only) to keep this aligned with the privacy contract.
|
|
Sentry.httpContextIntegration(),
|
|
],
|
|
sendDefaultPii: false,
|
|
|
|
beforeSend(event, hint) {
|
|
// Drop noisy local-AbortController RPC timeouts at the source so a
|
|
// missed `.catch()` at a future call site cannot regress the
|
|
// OPENHUMAN-REACT-15/11/10/12/Z/Y family. Sister to the Rust-side
|
|
// `is_session_expired_event` filter / loopback classifier in PR #2063.
|
|
// Cross-realm-safe: also accept a non-instanceof match on the
|
|
// class name + kind (test harness can construct CoreRpcError in a
|
|
// different module scope).
|
|
const original = hint?.originalException as unknown;
|
|
if (isCoreRpcTimeoutError(original)) {
|
|
return null;
|
|
}
|
|
|
|
// Always allow the smoke-test event through so pipeline validation works
|
|
// even when the user hasn't opted into analytics yet on first boot.
|
|
const isSmokeTest = event.message === 'react-sentry-smoke-test';
|
|
// Manual staging test events fired from the Developer Options button
|
|
// (#1072) bypass the consent gate so QA can validate the pipeline
|
|
// without needing to flip user-facing analytics first. The bypass is
|
|
// *also* gated on APP_ENVIRONMENT so a stray `manual-staging` tag in
|
|
// production (whether accidental or malicious) cannot exfiltrate an
|
|
// event past the consent gate — the only legitimate caller in this
|
|
// codebase is `triggerSentryTestEvent` and it itself refuses to fire
|
|
// outside staging.
|
|
const isManualTest = APP_ENVIRONMENT === 'staging' && event.tags?.test === 'manual-staging';
|
|
// Drop events when the user hasn't opted into analytics.
|
|
if (!isSmokeTest && !isManualTest && !isAnalyticsEnabled()) return null;
|
|
|
|
// Strip anything that could carry Redux / localStorage / request bodies.
|
|
event.breadcrumbs = [];
|
|
// Keep only the User-Agent header so Sentry's server-side relay can
|
|
// populate `os` / `browser` / `device` contexts (#1403). Drop URL,
|
|
// query string, cookies, and request body — anything that could leak
|
|
// user content or session state.
|
|
const ua = (event.request?.headers as Record<string, string> | undefined)?.['User-Agent'];
|
|
event.request = ua ? { headers: { 'User-Agent': ua } } : undefined;
|
|
delete event.extra;
|
|
event.contexts = {
|
|
os: event.contexts?.os,
|
|
browser: event.contexts?.browser,
|
|
device: event.contexts?.device,
|
|
};
|
|
|
|
// Tag with surface so events filter cleanly inside `openhuman-react`.
|
|
event.tags = { ...(event.tags ?? {}), surface: 'react' };
|
|
|
|
// Strip PII; keep a stable account id only.
|
|
const userId = getCoreStateSnapshot().snapshot.currentUser?._id;
|
|
event.user = userId ? { id: userId } : undefined;
|
|
|
|
// Strip frame-level local variables and source context — never send
|
|
// raw source snippets or live variable values to the dashboard.
|
|
if (event.exception?.values) {
|
|
for (const v of event.exception.values) {
|
|
if (v.stacktrace?.frames) {
|
|
for (const f of v.stacktrace.frames) {
|
|
delete f.vars;
|
|
delete f.context_line;
|
|
delete f.pre_context;
|
|
delete f.post_context;
|
|
}
|
|
}
|
|
if (v.mechanism) {
|
|
delete v.mechanism.data;
|
|
}
|
|
}
|
|
}
|
|
|
|
return event;
|
|
},
|
|
|
|
// Ignore common non-actionable errors.
|
|
ignoreErrors: ['ResizeObserver loop', 'Network request failed', 'Load failed', 'AbortError'],
|
|
});
|
|
|
|
// Optional smoke trigger for verifying the pipeline end-to-end. Set
|
|
// `VITE_SENTRY_SMOKE_TEST=true` for one build (or in `.env.local` for
|
|
// local verification) and the next initSentry call will fire a test
|
|
// message before returning. No-op when unset. The smoke event bypasses
|
|
// the analytics-consent gate in `beforeSend` so it reaches Sentry even
|
|
// on a fresh install where consent hasn't been granted yet.
|
|
if (SENTRY_SMOKE_TEST) {
|
|
Sentry.captureMessage('react-sentry-smoke-test', 'info');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-sync Sentry's enabled state after the user changes their consent.
|
|
* Called from onboarding and settings.
|
|
*
|
|
* `beforeSend` reads `isAnalyticsEnabled()` on every event, so toggling
|
|
* consent takes effect immediately for new errors. Flush pending events
|
|
* on opt-out so anything already in flight respects the previous state.
|
|
*
|
|
* Also updates the module-level `gaEnabled` flag so `trackPageView` and
|
|
* `trackEvent` respect the new consent state without reinitializing GA.
|
|
*/
|
|
export function syncAnalyticsConsent(enabled: boolean): void {
|
|
const client = Sentry.getClient();
|
|
if (client && !enabled) {
|
|
void Sentry.flush(2000);
|
|
}
|
|
|
|
analyticsEnabled = enabled;
|
|
analyticsConsentSynced = true;
|
|
if (gaInitialized || opInitialized) {
|
|
console.debug(`[analytics] consent updated: enabled=${enabled}`);
|
|
}
|
|
if (enabled) {
|
|
initializeAnalyticsProviders();
|
|
flushPendingAnalyticsEvents();
|
|
} else {
|
|
pendingAnalyticsEvents.length = 0;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Analytics — public API (GA4 + OpenPanel, both fire on every call)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function initGoogleAnalytics(): void {
|
|
if (gaInitialized || !GA_MEASUREMENT_ID) return;
|
|
try {
|
|
window.dataLayer = window.dataLayer || [];
|
|
window.gtag = function gtag(...args: [GtagCommand, ...unknown[]]) {
|
|
window.dataLayer.push(args);
|
|
};
|
|
window.gtag('js', new Date());
|
|
window.gtag('config', GA_MEASUREMENT_ID, {
|
|
send_page_view: false,
|
|
allow_ad_personalization_signals: false,
|
|
});
|
|
|
|
const script = document.createElement('script');
|
|
script.async = true;
|
|
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
|
|
document.head.appendChild(script);
|
|
|
|
gaInitialized = true;
|
|
console.debug('[analytics] GA initialized (gtag.js)', { measurementId: GA_MEASUREMENT_ID });
|
|
} catch (err) {
|
|
console.warn('[analytics] GA initialization failed:', err);
|
|
}
|
|
}
|
|
|
|
function initOpenPanel(): void {
|
|
if (opInitialized || !OPENPANEL_CLIENT_ID || !OPENPANEL_API_URL) return;
|
|
opInitialized = true;
|
|
console.debug('[analytics] OpenPanel initialized (direct ingestion)', {
|
|
clientId: OPENPANEL_CLIENT_ID,
|
|
apiUrl: OPENPANEL_API_URL,
|
|
});
|
|
}
|
|
|
|
function initializeAnalyticsProviders(): void {
|
|
initGoogleAnalytics();
|
|
initOpenPanel();
|
|
}
|
|
|
|
/**
|
|
* Initialize all analytics providers (GA4 + OpenPanel).
|
|
* Idempotent — each provider initializes at most once.
|
|
*/
|
|
export function initGA(): void {
|
|
analyticsEnabled = isAnalyticsEnabled();
|
|
if (analyticsEnabled) {
|
|
initializeAnalyticsProviders();
|
|
flushPendingAnalyticsEvents();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a privacy-limited page view to all initialized providers.
|
|
*/
|
|
export function trackPageView(path: string): void {
|
|
const pagePath = normalizeAnalyticsPagePath(path);
|
|
if (!analyticsEnabled) {
|
|
queuePendingAnalyticsEvent({
|
|
type: 'page_view',
|
|
name: 'screen_view',
|
|
params: { page: pagePath },
|
|
});
|
|
return;
|
|
}
|
|
if (!gaInitialized && !opInitialized) return;
|
|
console.debug('[analytics] trackPageView', { path: pagePath });
|
|
const properties = { page: pagePath, __path: pagePath, ...analyticsPageContextProperties() };
|
|
if (gaInitialized) {
|
|
window.gtag('event', 'page_view', {
|
|
page_path: pagePath,
|
|
page_location: analyticsPageLocation(),
|
|
...properties,
|
|
});
|
|
}
|
|
if (opInitialized) {
|
|
void sendOpenPanelTrack('screen_view', { ...properties, __title: currentDocumentTitle() });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a privacy-limited feature-engagement event to all initialized providers.
|
|
*
|
|
* Event names must appear in `ALLOWED_EVENTS`. Calls with unlisted names
|
|
* are dropped and a console warning is emitted.
|
|
*/
|
|
export function trackEvent(eventName: string, params?: AnalyticsParams): void {
|
|
if (!ALLOWED_EVENTS.has(eventName)) {
|
|
console.warn(
|
|
`[analytics] trackEvent dropped — '${eventName}' is not in ALLOWED_EVENTS allowlist`
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!analyticsEnabled) {
|
|
queuePendingAnalyticsEvent({ type: 'event', name: eventName, params });
|
|
return;
|
|
}
|
|
if (!gaInitialized && !opInitialized) return;
|
|
|
|
const properties = { ...(params ?? {}), ...analyticsContextProperties() };
|
|
const loggableProperties = { ...properties };
|
|
delete loggableProperties.user_id;
|
|
console.debug('[analytics] trackEvent', { eventName, params: loggableProperties });
|
|
if (gaInitialized) window.gtag('event', eventName, properties);
|
|
if (opInitialized) {
|
|
void sendOpenPanelTrack(eventName, properties);
|
|
}
|
|
}
|
|
|
|
export function startUiInteractionTracking(): () => void {
|
|
if (uiInteractionTrackingStarted || typeof document === 'undefined') return () => undefined;
|
|
uiInteractionTrackingStarted = true;
|
|
|
|
const handleClick = (event: MouseEvent) => {
|
|
const target = event.target instanceof Element ? event.target : null;
|
|
const element = target?.closest(INTERACTIVE_CLICK_SELECTOR);
|
|
if (!(element instanceof HTMLElement) || shouldSkipInteractionElement(element)) return;
|
|
|
|
trackEvent('ui_click', {
|
|
...interactionBaseProperties(element),
|
|
interaction_kind: 'click',
|
|
control_id: controlIdentifier(element),
|
|
destination: destinationForElement(element),
|
|
});
|
|
};
|
|
|
|
const handleChange = (event: Event) => {
|
|
const target = event.target instanceof Element ? event.target : null;
|
|
const element = target?.closest(CONTROL_CHANGE_SELECTOR);
|
|
if (!(element instanceof HTMLElement) || shouldSkipInteractionElement(element)) return;
|
|
|
|
trackEvent('ui_control_change', {
|
|
...interactionBaseProperties(element),
|
|
interaction_kind: 'change',
|
|
control_id: controlIdentifier(element),
|
|
control_state: controlState(element),
|
|
});
|
|
};
|
|
|
|
const handleSubmit = (event: SubmitEvent) => {
|
|
const form = event.target instanceof HTMLFormElement ? event.target : null;
|
|
if (!form || shouldSkipInteractionElement(form)) return;
|
|
|
|
trackEvent('ui_form_submit', {
|
|
...interactionBaseProperties(form),
|
|
interaction_kind: 'submit',
|
|
control_id: controlIdentifier(form),
|
|
});
|
|
};
|
|
|
|
document.addEventListener('click', handleClick, true);
|
|
document.addEventListener('change', handleChange, true);
|
|
document.addEventListener('submit', handleSubmit, true);
|
|
|
|
return () => {
|
|
document.removeEventListener('click', handleClick, true);
|
|
document.removeEventListener('change', handleChange, true);
|
|
document.removeEventListener('submit', handleSubmit, true);
|
|
uiInteractionTrackingStarted = false;
|
|
};
|
|
}
|
|
|
|
function queuePendingAnalyticsEvent(event: PendingAnalyticsEvent): void {
|
|
if (analyticsConsentSynced) return;
|
|
pendingAnalyticsEvents.push(event);
|
|
if (pendingAnalyticsEvents.length > MAX_PENDING_ANALYTICS_EVENTS) {
|
|
pendingAnalyticsEvents.splice(0, pendingAnalyticsEvents.length - MAX_PENDING_ANALYTICS_EVENTS);
|
|
}
|
|
}
|
|
|
|
function flushPendingAnalyticsEvents(): void {
|
|
if (!analyticsEnabled || pendingAnalyticsEvents.length === 0) return;
|
|
const events = pendingAnalyticsEvents.splice(0, pendingAnalyticsEvents.length);
|
|
for (const event of events) {
|
|
if (event.type === 'page_view') {
|
|
trackPageView(String(event.params?.page ?? event.name));
|
|
} else {
|
|
trackEvent(event.name, event.params);
|
|
}
|
|
}
|
|
}
|
|
|
|
function interactionBaseProperties(element: HTMLElement): AnalyticsParams {
|
|
return {
|
|
page: currentAppPath(),
|
|
page_hash: currentPageHash(),
|
|
element_tag: element.tagName.toLowerCase(),
|
|
element_role: scrubIdentifier(element.getAttribute('role')) ?? '',
|
|
element_type: scrubIdentifier(element.getAttribute('type')) ?? '',
|
|
};
|
|
}
|
|
|
|
function controlIdentifier(element: HTMLElement): string {
|
|
const explicit =
|
|
element.getAttribute('data-analytics-id') ??
|
|
element.getAttribute('data-track') ??
|
|
element.getAttribute('data-testid') ??
|
|
element.getAttribute('data-walkthrough') ??
|
|
element.getAttribute('name') ??
|
|
element.id;
|
|
const scrubbed = scrubIdentifier(explicit);
|
|
if (scrubbed) return scrubbed;
|
|
|
|
const hrefDestination = destinationForElement(element);
|
|
if (hrefDestination) return `link_${scrubIdentifier(hrefDestination) ?? 'internal'}`;
|
|
|
|
const container = nearestStableContainer(element);
|
|
const tag = element.tagName.toLowerCase();
|
|
if (container) return `${tag}_in_${container}`;
|
|
return tag;
|
|
}
|
|
|
|
function destinationForElement(element: HTMLElement): string {
|
|
const href = element instanceof HTMLAnchorElement ? element.getAttribute('href') : null;
|
|
if (!href) return '';
|
|
if (href.startsWith('#/')) return href.slice(1);
|
|
if (href.startsWith('/')) return href;
|
|
return href.startsWith('http') ? 'external' : '';
|
|
}
|
|
|
|
function controlState(element: HTMLElement): string {
|
|
if (element instanceof HTMLInputElement) {
|
|
if (element.type === 'checkbox' || element.type === 'radio') {
|
|
return element.checked ? 'checked' : 'unchecked';
|
|
}
|
|
if (element.type === 'range') return 'changed';
|
|
}
|
|
if (element instanceof HTMLSelectElement) return 'selected';
|
|
|
|
const ariaChecked = element.getAttribute('aria-checked');
|
|
if (ariaChecked === 'true' || ariaChecked === 'false' || ariaChecked === 'mixed') {
|
|
return ariaChecked;
|
|
}
|
|
return 'changed';
|
|
}
|
|
|
|
function nearestStableContainer(element: HTMLElement): string | undefined {
|
|
const container = element.closest('[data-testid],[data-walkthrough],[data-analytics-id]');
|
|
if (!(container instanceof HTMLElement) || container === element) return undefined;
|
|
return scrubIdentifier(
|
|
container.getAttribute('data-analytics-id') ??
|
|
container.getAttribute('data-testid') ??
|
|
container.getAttribute('data-walkthrough')
|
|
);
|
|
}
|
|
|
|
function shouldSkipInteractionElement(element: HTMLElement): boolean {
|
|
if (element.closest('[data-analytics-skip="true"],[data-no-analytics="true"]')) return true;
|
|
if (element.closest('[contenteditable="true"]')) return true;
|
|
if (element instanceof HTMLInputElement) {
|
|
return ['text', 'search', 'email', 'password', 'tel', 'url', 'number', 'file'].includes(
|
|
element.type
|
|
);
|
|
}
|
|
if (element instanceof HTMLTextAreaElement) return true;
|
|
return false;
|
|
}
|
|
|
|
function scrubIdentifier(value: string | null | undefined): string | undefined {
|
|
const trimmed = value?.trim();
|
|
if (!trimmed) return undefined;
|
|
const withoutQuery = trimmed.split(/[?#]/)[0] ?? trimmed;
|
|
const scrubbed = withoutQuery
|
|
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, ':email')
|
|
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id')
|
|
.replace(/\b[0-9a-f]{16,}\b/gi, ':id')
|
|
.replace(/\b\d{3,}\b/g, ':num')
|
|
.replace(/[^a-zA-Z0-9:_/-]+/g, '_')
|
|
.replace(/_+/g, '_')
|
|
.replace(/^_+|_+$/g, '')
|
|
.toLowerCase()
|
|
.slice(0, 80);
|
|
return scrubbed || undefined;
|
|
}
|
|
|
|
function currentAppPath(): string {
|
|
if (typeof window === 'undefined') return '';
|
|
return normalizeAnalyticsPagePath(window.location.pathname);
|
|
}
|
|
|
|
function currentPageHash(): string {
|
|
if (typeof window === 'undefined') return '';
|
|
return window.location.hash.startsWith('#/') ? window.location.hash : '';
|
|
}
|
|
|
|
function normalizeAnalyticsPagePath(path: string): string {
|
|
if (typeof window !== 'undefined' && window.location.hash.startsWith('#/')) {
|
|
return hashToPath(window.location.hash);
|
|
}
|
|
if (path.startsWith('#/')) return hashToPath(path);
|
|
return path || '/';
|
|
}
|
|
|
|
function hashToPath(hash: string): string {
|
|
const withoutHash = hash.slice(1);
|
|
return withoutHash || '/';
|
|
}
|
|
|
|
async function sendOpenPanelTrack(eventName: string, params?: AnalyticsParams): Promise<void> {
|
|
const profileId = currentAnalyticsUserId();
|
|
const properties = {
|
|
__path: currentOpenPanelPath(),
|
|
__referrer: currentDocumentReferrer(),
|
|
__timestamp: new Date().toISOString(),
|
|
...(params ?? {}),
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(OPENPANEL_TRACK_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'openpanel-client-id': OPENPANEL_CLIENT_ID,
|
|
'openpanel-sdk-name': 'openhuman-react',
|
|
'openpanel-sdk-version': '0.1.0',
|
|
},
|
|
body: JSON.stringify({
|
|
type: 'track',
|
|
payload: { name: eventName, ...(profileId ? { profileId } : {}), properties },
|
|
}),
|
|
keepalive: true,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.warn('[analytics] OpenPanel track failed:', response.status, await response.text());
|
|
}
|
|
} catch (err) {
|
|
console.warn('[analytics] OpenPanel track failed:', err);
|
|
}
|
|
}
|
|
|
|
function analyticsContextProperties(): AnalyticsParams {
|
|
const userId = currentAnalyticsUserId();
|
|
return {
|
|
user_id: userId ?? '',
|
|
app_version: APP_VERSION,
|
|
binary_version: APP_BINARY_VERSION,
|
|
core_cargo_version: CORE_CARGO_VERSION,
|
|
tauri_cargo_version: TAURI_CARGO_VERSION,
|
|
release: SENTRY_RELEASE,
|
|
build_sha: BUILD_SHA,
|
|
app_environment: APP_ENVIRONMENT,
|
|
};
|
|
}
|
|
|
|
function analyticsPageContextProperties(): AnalyticsParams {
|
|
return { ...analyticsContextProperties(), page_hash: currentPageHash() };
|
|
}
|
|
|
|
function currentAnalyticsUserId(): string | undefined {
|
|
return getCoreStateSnapshot().snapshot.currentUser?._id;
|
|
}
|
|
|
|
function currentOpenPanelPath(): string {
|
|
return currentAppPath();
|
|
}
|
|
|
|
function analyticsPageLocation(): string {
|
|
if (typeof window === 'undefined') return '';
|
|
const pagePath = currentAppPath();
|
|
return `${window.location.origin}${pagePath}`;
|
|
}
|
|
|
|
function currentDocumentTitle(): string {
|
|
if (typeof document === 'undefined') return '';
|
|
return document.title;
|
|
}
|
|
|
|
function currentDocumentReferrer(): string {
|
|
if (typeof document === 'undefined') return '';
|
|
return document.referrer;
|
|
}
|
|
|
|
/**
|
|
* Fire a manual diagnostic event for issue #1072: a staging-only "Trigger
|
|
* Sentry Test" button uses this to validate the React → Sentry pipeline
|
|
* end-to-end after a config change. Tagged so `beforeSend` lets it through
|
|
* regardless of analytics consent, and so it's trivial to filter on the
|
|
* dashboard side. Returns the event id Sentry assigns (or `undefined` if
|
|
* Sentry is disabled in this build).
|
|
*/
|
|
export async function triggerSentryTestEvent(): Promise<string | undefined> {
|
|
// Fail-fast outside staging. The UI button is only rendered when
|
|
// `APP_ENVIRONMENT === 'staging'`, but this guard exists as defense in
|
|
// depth so a programmatic caller (a stray import, a future refactor)
|
|
// cannot fire diagnostic events from production. `beforeSend` already
|
|
// re-checks the same gate before applying the consent bypass.
|
|
if (APP_ENVIRONMENT !== 'staging') {
|
|
console.warn(
|
|
`[sentry-test] refusing to fire test event outside staging (APP_ENVIRONMENT=${APP_ENVIRONMENT})`
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
const client = Sentry.getClient();
|
|
if (!client) {
|
|
console.warn('[sentry-test] Sentry client not initialized — DSN missing or dev build');
|
|
return undefined;
|
|
}
|
|
|
|
// Constant message so Sentry's default grouping algorithm collapses every
|
|
// QA click into one issue (with N events) instead of one issue per click.
|
|
// Per-click timing goes through `extra` so it's still visible on each
|
|
// event but doesn't influence the fingerprint.
|
|
const stamp = new Date().toISOString();
|
|
const error = new Error('Manual Sentry test from staging UI');
|
|
error.name = 'SentryStagingTestError';
|
|
|
|
const eventId = Sentry.captureException(error, {
|
|
tags: { test: 'manual-staging', source: 'developer-options-button' },
|
|
extra: { triggered_at: stamp },
|
|
level: 'error',
|
|
});
|
|
|
|
console.info('[sentry-test] captureException eventId=', eventId);
|
|
// Surface flush timeouts as failures: a `false` here means the event
|
|
// queue did not drain within 2s, so the network round-trip to Sentry is
|
|
// unconfirmed. For a *diagnostic* tool, returning a successful-looking
|
|
// eventId in that case would be a lie.
|
|
const flushed = await Sentry.flush(2000);
|
|
if (!flushed) {
|
|
throw new Error(
|
|
'Sentry.flush(2000) timed out — event may not have reached Sentry. ' +
|
|
'Check network / DSN / Sentry status before retrying.'
|
|
);
|
|
}
|
|
return eventId;
|
|
}
|