fix(sentry): auto-send React events; collapse core→tauri for desktop (#1086)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
CodeGhost21
2026-05-01 15:11:42 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 1a30a0ab4d
commit a084ebf45c
14 changed files with 174 additions and 1053 deletions
+53 -33
View File
@@ -280,6 +280,10 @@ jobs:
CORE_MANIFEST: ${{ steps.core-paths.outputs.core_manifest }}
CORE_BIN_NAME: ${{ steps.core-paths.outputs.core_bin_name }}
OPENHUMAN_APP_ENV: staging
# Bake the short SHA into the CLI binary so build_release_tag() in
# src/main.rs produces openhuman@<version>+<sha> — matching the
# Sentry release tag used when uploading the standalone CLI symbols.
OPENHUMAN_BUILD_SHA: ${{ needs.prepare-build.outputs.short_sha }}
- name: Stage sidecar for Tauri bundler
shell: bash
run: |
@@ -331,24 +335,31 @@ jobs:
run: |
NODE_OPTIONS="--max-old-space-size=8192" cargo tauri build --debug -c "$TAURI_CONFIG_OVERRIDE" $MATRIX_ARGS
# Upload Rust debug info to Sentry so backend + Tauri-shell stack
# traces symbolicate in the staging Sentry project too. Frontend
# source maps are handled by sentry-vite-plugin in the build step
# above; this is the Rust half. Symbols are keyed by debug-ID, so
# it's safe to run per-matrix-target without collisions — Sentry
# merges artifacts across platforms.
- name: Upload core sidecar debug symbols to Sentry
# Upload Rust debug info to Sentry so all Rust stack traces
# symbolicate in the staging Sentry projects. Frontend source maps
# are handled by sentry-vite-plugin in the build step above; this
# is the Rust half.
#
# Two distinct binaries → two distinct Sentry projects:
# - **Tauri shell** (`app/src-tauri/target/<triple>/debug`) — the
# actual desktop app users run. Core lives in-process here as a
# linked path dep, so the shell's DWARF covers core code too.
# Events all route to `openhuman-tauri` (one `sentry::init` per
# process, in `app/src-tauri/src/lib.rs::run()`).
# - **Standalone CLI** (`target/<triple>/debug`) — the separate
# `openhuman-core` binary built by "Build sidecar core binary".
# Has its own `sentry::init` in `src/main.rs` and reports to
# `openhuman-core`. Currently unused at runtime in the desktop
# build but kept symbolicated for any operator running it.
# Symbols are keyed by debug-ID so cross-platform / cross-binary
# uploads don't collide — Sentry just merges them.
- name: Upload Tauri shell debug symbols to Sentry (tauri project)
if: env.SENTRY_AUTH_TOKEN != ''
shell: bash
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_CORE }}
# Must match the release tag the running core binary reports
# (`openhuman@<version>+<short_sha>`, see `build_release_tag` in
# src/main.rs). Uses short_sha (12 chars) — see the prepare-build
# outputs comment. If this drifts, DIFs attach to a different
# release than events and stack traces stay un-symbolicated.
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_TAURI }}
SENTRY_RELEASE:
openhuman@${{ needs.prepare-build.outputs.version }}+${{
needs.prepare-build.outputs.short_sha }}
@@ -356,26 +367,35 @@ jobs:
MATRIX_TARGET: ${{ matrix.settings.target }}
run: |
set -euo pipefail
# Two DIF locations on staging:
# - `app/src-tauri/target/<triple>/debug` — Tauri shell binary
# (which links the core as a path dep, so its DWARF covers core
# too).
# - `target/<triple>/debug` — standalone `openhuman-core` CLI
# binary built by the "Build sidecar core binary" step earlier
# and published as a separate artifact. Crashes from operators
# running the standalone CLI need its DIFs to symbolicate.
# Symbols are keyed by debug-ID so cross-platform / cross-binary
# uploads don't collide — Sentry just merges them.
for dif_dir in \
"app/src-tauri/target/${MATRIX_TARGET}/debug" \
"target/${MATRIX_TARGET}/debug"; do
if [ -d "$dif_dir" ]; then
echo "==> Uploading symbols from $dif_dir to ${SENTRY_PROJECT}"
bash scripts/upload_sentry_symbols.sh "$VERSION" "$dif_dir"
else
echo "==> Skipping $dif_dir (not present)"
fi
done
dif_dir="app/src-tauri/target/${MATRIX_TARGET}/debug"
if [ -d "$dif_dir" ]; then
echo "==> Uploading symbols from $dif_dir to ${SENTRY_PROJECT}"
bash scripts/upload_sentry_symbols.sh "$VERSION" "$dif_dir"
else
echo "==> Skipping $dif_dir (not present)"
fi
- name: Upload standalone core CLI debug symbols to Sentry (core project)
if: env.SENTRY_AUTH_TOKEN != ''
shell: bash
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_CORE }}
SENTRY_RELEASE:
openhuman@${{ needs.prepare-build.outputs.version }}+${{
needs.prepare-build.outputs.short_sha }}
VERSION: ${{ needs.prepare-build.outputs.version }}
MATRIX_TARGET: ${{ matrix.settings.target }}
run: |
set -euo pipefail
dif_dir="target/${MATRIX_TARGET}/debug"
if [ -d "$dif_dir" ]; then
echo "==> Uploading symbols from $dif_dir to ${SENTRY_PROJECT}"
bash scripts/upload_sentry_symbols.sh "$VERSION" "$dif_dir"
else
echo "==> Skipping $dif_dir (not present)"
fi
- name: Upload staging desktop bundles
uses: actions/upload-artifact@v4
+25 -19
View File
@@ -514,14 +514,19 @@ jobs:
NODE_OPTIONS="--max-old-space-size=8192" cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" $MATRIX_ARGS
fi
# Upload Rust debug info to Sentry so backend + Tauri-shell stack traces
# symbolicate in production. The frontend source maps are handled by
# sentry-vite-plugin in the build step above; this is the Rust half.
# Core sidecar and Tauri shell go to **separate Sentry projects**
# (matching their separate DSNs) so events show up in the right place.
# Symbols are keyed by debug-ID, so it's safe to run per-matrix-target
# without collisions — Sentry merges artifacts across platforms.
- name: Upload core sidecar debug symbols to Sentry
# Upload Rust debug info to Sentry so all Rust stack traces (Tauri
# shell + linked-in core) symbolicate in production. The frontend
# source maps are handled by sentry-vite-plugin in the build step
# above; this is the Rust half.
#
# Since #1061 the core lives in-process as a library linked into the
# Tauri shell binary — there is exactly one Rust process and one
# `sentry::init` call (in `app/src-tauri/src/lib.rs::run()`), so all
# Rust events route to `openhuman-tauri`. Upload DIFs to that same
# project so they actually attach to the right events. Symbols are
# keyed by debug-ID, so it's safe to run per-matrix-target without
# collisions — Sentry merges artifacts across platforms.
- name: Upload Rust debug symbols to Sentry (tauri project)
if:
needs.prepare-build.outputs.release_enabled == 'true' && env.SENTRY_AUTH_TOKEN
!= ''
@@ -529,12 +534,13 @@ jobs:
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_CORE }}
# Must match the release tag the running core binary reports
# (`openhuman@<version>+<short_sha>`, see `build_release_tag` in
# src/main.rs). Uses short_sha (12 chars) — see the prepare-build
# outputs comment. If this drifts, DIFs attach to a different
# release than events and stack traces stay un-symbolicated.
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_TAURI }}
# Must match the release tag the running binary reports
# (`openhuman@<version>+<short_sha>`, see `build_sentry_release_tag`
# in `app/src-tauri/src/lib.rs`). Uses short_sha (12 chars) — see
# the prepare-build outputs comment. If this drifts, DIFs attach
# to a different release than events and stack traces stay
# un-symbolicated.
SENTRY_RELEASE:
openhuman@${{ needs.prepare-build.outputs.version }}+${{
needs.prepare-build.outputs.short_sha }}
@@ -545,12 +551,12 @@ jobs:
# Core is linked into the Tauri binary as a path dep, so all Rust
# debug info — core + shell — lives under the shell's target dir.
# upload-dif scans recursively.
deps_dir="app/src-tauri/target/${MATRIX_TARGET}/release/deps"
if [ -d "$deps_dir" ]; then
echo "==> Uploading symbols from $deps_dir to ${SENTRY_PROJECT}"
bash scripts/upload_sentry_symbols.sh "$VERSION" "$deps_dir"
dif_dir="app/src-tauri/target/${MATRIX_TARGET}/release"
if [ -d "$dif_dir" ]; then
echo "==> Uploading symbols from $dif_dir to ${SENTRY_PROJECT}"
bash scripts/upload_sentry_symbols.sh "$VERSION" "$dif_dir"
else
echo "==> Skipping $deps_dir (not present)"
echo "==> Skipping $dif_dir (not present)"
fi
# tauri-action previously uploaded non-macOS installer assets directly
+6
View File
@@ -37,6 +37,12 @@ VITE_SENTRY_DSN=
# falls back to `openhuman@<version>`).
VITE_BUILD_SHA=
# [optional] One-shot Sentry pipeline smoke test. When `true`, the next
# `initSentry()` call dispatches a `react-sentry-smoke-test` event so you
# can confirm the DSN, source maps, and release tagging are wired
# end-to-end. Leave blank in normal builds.
# VITE_SENTRY_SMOKE_TEST=true
# [CI-only] Sentry source-map upload — set on CI to enable
# `@sentry/vite-plugin`. Leave blank locally; the plugin skips when
# `SENTRY_AUTH_TOKEN` is empty.
-4
View File
@@ -56,10 +56,6 @@
"libx11-6",
"libgdk-pixbuf-2.0-0",
"libglib2.0-0"
],
"args": [
"--enable-features=UseOzonePlatform",
"--ozone-platform=x11"
]
}
},
+1 -5
View File
@@ -21,7 +21,6 @@ import { startWebviewNotificationsService } from './lib/webviewNotifications';
import ChatRuntimeProvider from './providers/ChatRuntimeProvider';
import CoreStateProvider, { useCoreState } from './providers/CoreStateProvider';
import SocketProvider from './providers/SocketProvider';
import { tagErrorSource } from './services/errorReportQueue';
import { startWebviewAccountService } from './services/webviewAccountService';
import { persistor, store } from './store';
import { useAppDispatch, useAppSelector } from './store/hooks';
@@ -42,10 +41,7 @@ function App() {
<Sentry.ErrorBoundary
fallback={({ error, componentStack, resetError }) => (
<ErrorFallbackScreen error={error} componentStack={componentStack} onReset={resetError} />
)}
onError={(_error, componentStack, eventId) => {
tagErrorSource(eventId, 'react', componentStack);
}}>
)}>
<Provider store={store}>
<PersistGate loading={<PersistRehydrationScreen />} persistor={persistor}>
<CoreStateProvider>
+3 -2
View File
@@ -8,8 +8,9 @@ import { openUrl } from '../utils/openUrl';
* a catastrophic React render error. Self-contained with zero dependencies
* on Redux, Router, or any context provider.
*
* The ErrorReportNotification lives in a separate React root, so the user
* can still review and report the error from this screen.
* Errors caught by the boundary are auto-forwarded to Sentry by the
* `Sentry.ErrorBoundary` wrapper in `App.tsx` (subject to user analytics
* consent enforced in `analytics.ts::beforeSend`).
*/
interface ErrorFallbackScreenProps {
@@ -1,218 +0,0 @@
/**
* ErrorReportNotification
*
* Non-blocking notification UI rendered via createPortal into its own React root.
* Subscribes to the error report queue and lets users inspect, dismiss, or
* report each error individually.
*/
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { isAnalyticsEnabled } from '../services/analytics';
import {
dequeueError,
getErrors,
type PendingErrorReport,
sendToSentry,
subscribe,
} from '../services/errorReportQueue';
const MAX_VISIBLE = 3;
const AUTO_DISMISS_MS = 30_000;
// ---------------------------------------------------------------------------
// Single notification card
// ---------------------------------------------------------------------------
function NotificationCard({
report,
onDismiss,
}: {
report: PendingErrorReport;
onDismiss: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [sent, setSent] = useState(false);
const [exiting, setExiting] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const analyticsEnabled = isAnalyticsEnabled();
const isDevOnly = !report.sentryEvent;
const animateOut = useCallback(
(id: string) => {
setExiting(true);
setTimeout(() => onDismiss(id), 200);
},
[onDismiss]
);
// Auto-dismiss timer
useEffect(() => {
timerRef.current = setTimeout(() => {
animateOut(report.id);
}, AUTO_DISMISS_MS);
return () => clearTimeout(timerRef.current);
}, [report.id, animateOut]);
const handleDismiss = useCallback(() => {
clearTimeout(timerRef.current);
animateOut(report.id);
}, [report.id, animateOut]);
const handleReport = useCallback(() => {
clearTimeout(timerRef.current);
const ok = sendToSentry(report);
if (ok) {
setSent(true);
setTimeout(() => onDismiss(report.id), 1200);
}
}, [report, onDismiss]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') handleDismiss();
if (e.key === 'Enter') setExpanded(prev => !prev);
},
[handleDismiss]
);
return (
<div
role="alert"
aria-live="assertive"
tabIndex={0}
onKeyDown={handleKeyDown}
className={`w-[420px] bg-stone-900 border border-stone-700/50 rounded-2xl shadow-large overflow-hidden transition-all duration-200 ${
exiting ? 'opacity-0 translate-y-2' : 'animate-fade-up opacity-100'
}`}>
{/* Header */}
<div className="flex items-start gap-3 px-4 pt-4 pb-2">
{/* Error icon */}
<div className="flex-shrink-0 mt-0.5">
<svg
className="w-5 h-5 text-coral-500"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true">
<path
fillRule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.168 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 6a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 6zm0 9a1 1 0 100-2 1 1 0 000 2z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white truncate">{report.title}</span>
{isDevOnly && (
<span className="flex-shrink-0 text-[10px] font-medium px-1.5 py-0.5 bg-amber-500/20 text-amber-400 rounded">
DEV
</span>
)}
</div>
<p className="text-xs text-stone-400 mt-0.5 line-clamp-2">{report.message}</p>
</div>
{/* Close button */}
<button
onClick={handleDismiss}
className="flex-shrink-0 p-1 text-stone-500 hover:text-stone-300 transition-colors rounded"
aria-label="Dismiss error notification">
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.28 3.22a.75.75 0 00-1.06 1.06L6.94 8l-3.72 3.72a.75.75 0 101.06 1.06L8 9.06l3.72 3.72a.75.75 0 101.06-1.06L9.06 8l3.72-3.72a.75.75 0 00-1.06-1.06L8 6.94 4.28 3.22z" />
</svg>
</button>
</div>
{/* Expand toggle */}
{report.sentryEvent && (
<button
onClick={() => setExpanded(prev => !prev)}
className="text-xs text-stone-500 hover:text-stone-300 transition-colors px-4 pb-2">
{expanded ? 'Hide details' : 'View details'}
</button>
)}
{/* Expanded payload viewer */}
{expanded && report.sentryEvent && (
<div className="px-4 pb-3">
<pre className="bg-stone-800/50 rounded-xl border border-stone-700/50 p-3 font-mono text-xs text-stone-300 max-h-[300px] overflow-auto whitespace-pre-wrap break-words">
{JSON.stringify(report.sentryEvent, null, 2)}
</pre>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2 px-4 pb-4">
<button
onClick={handleDismiss}
className="bg-stone-700 hover:bg-stone-600 text-white text-xs rounded-lg px-3 py-1.5 transition-colors">
Dismiss
</button>
{sent ? (
<span className="text-xs text-sage-400 px-3 py-1.5">Sent</span>
) : isDevOnly ? (
<span className="text-xs text-stone-500 px-3 py-1.5">Console only</span>
) : !analyticsEnabled ? (
<button
disabled
className="bg-stone-700/50 text-stone-500 text-xs rounded-lg px-3 py-1.5 cursor-not-allowed"
title="Enable analytics in Settings to report errors">
Report
</button>
) : (
<button
onClick={handleReport}
className="bg-coral-500 hover:bg-coral-600 text-white text-xs rounded-lg px-3 py-1.5 transition-colors">
Report
</button>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main notification container
// ---------------------------------------------------------------------------
export default function ErrorReportNotification() {
const errors = useSyncExternalStore(subscribe, getErrors, getErrors);
const handleDismiss = useCallback((id: string) => {
dequeueError(id);
}, []);
// Escape key dismisses topmost notification
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && errors.length > 0) {
dequeueError(errors[errors.length - 1].id);
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [errors]);
if (errors.length === 0) return null;
const visible = errors.slice(-MAX_VISIBLE);
const hiddenCount = errors.length - MAX_VISIBLE;
return createPortal(
<div className="fixed bottom-4 right-4 z-[10000] flex flex-col-reverse gap-2 items-end">
{visible.map(report => (
<NotificationCard key={report.id} report={report} onDismiss={handleDismiss} />
))}
{hiddenCount > 0 && (
<div className="text-xs text-stone-400 bg-stone-800/80 border border-stone-700/50 rounded-lg px-3 py-1.5">
+{hiddenCount} more {hiddenCount === 1 ? 'error' : 'errors'}
</div>
)}
</div>,
document.body
);
}
-9
View File
@@ -5,7 +5,6 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import ErrorReportNotification from './components/ErrorReportNotification';
import './index.css';
import { getCoreStateSnapshot } from './lib/coreState/store';
import OverlayApp from './overlay/OverlayApp';
@@ -55,14 +54,6 @@ if (!isOverlayWindow) {
function bootRender() {
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(<React.StrictMode>{isOverlayWindow ? <OverlayApp /> : <App />}</React.StrictMode>);
if (!isOverlayWindow) {
// Mount error notification in an isolated React root so it survives App crashes.
const errorRoot = document.createElement('div');
errorRoot.id = 'error-report-root';
document.body.appendChild(errorRoot);
ReactDOM.createRoot(errorRoot).render(<ErrorReportNotification />);
}
}
getActiveUserIdFromCore()
+68 -150
View File
@@ -1,220 +1,138 @@
/**
* Analytics & Sentry service
*
* Manages Sentry error reporting gated behind user analytics consent.
* Designed to capture ONLY:
* - Error message & stack trace
* - Device / browser metadata (via Sentry's default user-agent parsing)
* - Source file location (via source maps)
* Initializes Sentry for the React frontend with auto-send semantics:
* captured errors are sanitized in `beforeSend` and forwarded to Sentry,
* gated only by user analytics consent.
*
* Explicitly strips:
* - All breadcrumbs (console, click, network, etc.)
* - Redux state / localStorage / sessionStorage
* - User PII (IP address, cookies)
* - Request bodies / headers
* - Session replay
*
* Error flow: beforeSend intercepts all events, sanitizes them, queues them
* in the errorReportQueue for user opt-in, and returns null to prevent
* auto-sending. Users can then review and explicitly report each error.
* 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 anonymous id (or omitted)
* - `sendDefaultPii: false` (no IP, no cookies)
* - All breadcrumb-producing integrations disabled
*/
import * as Sentry from '@sentry/react';
import { getCoreStateSnapshot } from '../lib/coreState/store';
import { APP_ENVIRONMENT, IS_DEV, SENTRY_DSN, SENTRY_RELEASE } from '../utils/config';
import { enqueueError, registerSentrySender, type SanitizedSentryEvent } from './errorReportQueue';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Strip sensitive fields from the exception object before including it
* in the sanitized event shown to the user and sent to Sentry.
*
* Removes: local variables (vars), source code lines (context_line,
* pre_context, post_context), mechanism.data, and module_metadata.
*/
function sanitizeException(
exception: Sentry.Event['exception']
): SanitizedSentryEvent['exception'] {
if (!exception?.values) return undefined;
return {
values: exception.values.map(entry => ({
type: entry.type ?? 'Error',
value: entry.value ?? '',
stacktrace: entry.stacktrace?.frames
? {
frames: entry.stacktrace.frames.map(frame => ({
filename: frame.filename,
function: frame.function,
module: frame.module,
lineno: frame.lineno,
colno: frame.colno,
abs_path: frame.abs_path,
in_app: frame.in_app,
// Stripped: vars, context_line, pre_context, post_context,
// instruction_addr, addr_mode, debug_id, module_metadata
})),
}
: undefined,
mechanism: entry.mechanism
? { type: entry.mechanism.type, handled: entry.mechanism.handled }
: undefined,
// Stripped: mechanism.data (arbitrary key-value pairs)
})),
};
}
import {
APP_ENVIRONMENT,
IS_DEV,
SENTRY_DSN,
SENTRY_RELEASE,
SENTRY_SMOKE_TEST,
} from '../utils/config';
/** Check if the current user has opted into analytics. */
export function isAnalyticsEnabled(): boolean {
return getCoreStateSnapshot().snapshot.analyticsEnabled;
}
// ---------------------------------------------------------------------------
// Bypass flag — when true, beforeSend passes the event through to Sentry
// ---------------------------------------------------------------------------
let _bypassBeforeSend = false;
// ---------------------------------------------------------------------------
// Sentry initialisation
// ---------------------------------------------------------------------------
export function initSentry(): void {
if (!SENTRY_DSN) return;
Sentry.init({
dsn: SENTRY_DSN,
environment: APP_ENVIRONMENT,
// Canonical release tag shared with the core sidecar and source-map
// upload (see @sentry/vite-plugin in app/vite.config.ts). Lets events
// from all surfaces group under a single Sentry release with
// symbolicated stack traces.
// 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, // disable in dev builds
enabled: !IS_DEV,
// -----------------------------------------------------------------------
// Privacy: disable EVERYTHING that could leak sensitive state
// -----------------------------------------------------------------------
// No session replay
// Privacy: disable EVERYTHING that could leak sensitive state.
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
// No performance / tracing
tracesSampleRate: 0,
// No breadcrumbs at all (console, clicks, network, etc.)
defaultIntegrations: false,
integrations: [
// Only keep the bare-minimum integrations for stack traces
Sentry.functionToStringIntegration(),
Sentry.linkedErrorsIntegration(),
Sentry.dedupeIntegration(),
Sentry.browserApiErrorsIntegration(),
Sentry.globalHandlersIntegration(),
],
// Strip IP address
sendDefaultPii: false,
// -----------------------------------------------------------------------
// Intercept every event: sanitize, queue for user opt-in, block auto-send
// -----------------------------------------------------------------------
beforeSend(event) {
// Bypass mode: let the event through (used by sendEventToSentry)
if (_bypassBeforeSend) {
_bypassBeforeSend = false;
return event;
}
// 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';
// Drop events when the user hasn't opted into analytics.
if (!isSmokeTest && !isAnalyticsEnabled()) return null;
// --- Sanitize the event ---
// Strip any breadcrumbs that somehow snuck in
// Strip anything that could carry Redux / localStorage / request bodies.
event.breadcrumbs = [];
// Strip request data (cookies, headers, body)
delete event.request;
// Strip user PII — keep only a stable anonymous ID
const userId = getCoreStateSnapshot().snapshot.currentUser?._id;
event.user = userId ? { id: userId } : undefined;
// Strip any extra/contexts that could contain Redux or localStorage data
delete event.extra;
event.contexts = {
// Keep only OS / browser / device metadata
os: event.contexts?.os,
browser: event.contexts?.browser,
device: event.contexts?.device,
};
// --- Build a sanitized snapshot for the user to inspect ---
const sanitized: SanitizedSentryEvent = {
event_id: event.event_id ?? crypto.randomUUID().replace(/-/g, ''),
timestamp: typeof event.timestamp === 'number' ? event.timestamp : Date.now() / 1000,
platform: event.platform ?? 'javascript',
exception: sanitizeException(event.exception),
contexts: event.contexts as SanitizedSentryEvent['contexts'],
user: event.user as SanitizedSentryEvent['user'],
tags: event.tags as Record<string, string> | undefined,
environment: IS_DEV ? 'development' : 'production',
};
// Tag with surface so events filter cleanly inside `openhuman-react`.
event.tags = { ...(event.tags ?? {}), surface: 'react' };
// Extract human-readable title + message from the exception
const firstException = event.exception?.values?.[0];
const title = firstException?.type ?? 'Error';
const message = firstException?.value ?? 'Unknown error';
// Strip PII; keep a stable anonymous user id only.
const userId = getCoreStateSnapshot().snapshot.currentUser?._id;
event.user = userId ? { id: userId } : undefined;
// Queue the error for the notification UI
enqueueError({
id: crypto.randomUUID(),
timestamp: Date.now(),
source: 'global',
title,
message,
sentryEvent: sanitized,
});
// 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 null to prevent Sentry from auto-sending
return null;
return event;
},
beforeSendTransaction() {
// Block all transactions (performance traces)
// Block all transactions (performance traces).
return null;
},
// Ignore common non-actionable errors
// Ignore common non-actionable errors.
ignoreErrors: ['ResizeObserver loop', 'Network request failed', 'Load failed', 'AbortError'],
});
// Register the bypass sender so the error queue can actually send events
registerSentrySender((sanitizedEvent: SanitizedSentryEvent) => {
_bypassBeforeSend = true;
Sentry.captureEvent(sanitizedEvent as unknown as Sentry.Event);
});
// 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');
}
}
// ---------------------------------------------------------------------------
// Consent sync — call when the user toggles analytics on/off
// ---------------------------------------------------------------------------
/**
* 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.
*/
export function syncAnalyticsConsent(enabled: boolean): void {
const client = Sentry.getClient();
if (!client) return;
if (enabled) {
// Client is already initialised; events will pass through beforeSend
// because isAnalyticsEnabled() will now return true.
} else {
// Flush any pending events, then future events will be dropped by beforeSend.
if (!enabled) {
void Sentry.flush(2000);
}
}
-361
View File
@@ -1,361 +0,0 @@
/**
* @vitest-environment jsdom
*
* Tests for errorReportQueue — module-level error queue with no React/Redux/Sentry deps.
*
* Because the module holds mutable singleton state, we re-import it fresh in each
* describe block via `vi.resetModules()` to isolate tests from each other.
*
* The source module calls `initGlobalListeners()` at load time, which reads `window`
* — the explicit environment directive ensures jsdom is active even when Vitest picks
* a different default for a given run.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// setup.ts already mocks @sentry/react and ../utils/config globally.
// We rely on those global mocks here.
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeReport(
id: string,
overrides: Partial<{
title: string;
message: string;
source: 'react' | 'global' | 'manual';
timestamp: number;
}> = {}
) {
return {
id,
timestamp: overrides.timestamp ?? Date.now(),
source: overrides.source ?? ('manual' as const),
title: overrides.title ?? `Error ${id}`,
message: overrides.message ?? `Message for ${id}`,
sentryEvent: null,
};
}
// ---------------------------------------------------------------------------
// enqueueError / getErrors / dequeueError
// ---------------------------------------------------------------------------
describe('errorReportQueue — enqueue / dequeue / getErrors', () => {
let eq: typeof import('./errorReportQueue');
beforeEach(async () => {
vi.resetModules();
eq = await import('./errorReportQueue');
});
afterEach(() => {
vi.useRealTimers();
});
it('starts with an empty queue', () => {
expect(eq.getErrors()).toEqual([]);
});
it('adds a report and returns it from getErrors', () => {
const report = makeReport('r1');
eq.enqueueError(report);
const queue = eq.getErrors();
expect(queue).toHaveLength(1);
expect(queue[0].id).toBe('r1');
});
it('removes a report via dequeueError', () => {
eq.enqueueError(makeReport('r1'));
eq.enqueueError(makeReport('r2'));
eq.dequeueError('r1');
const queue = eq.getErrors();
expect(queue).toHaveLength(1);
expect(queue[0].id).toBe('r2');
});
it('is a no-op when dequeuing a non-existent ID', () => {
eq.enqueueError(makeReport('r1'));
eq.dequeueError('does-not-exist');
expect(eq.getErrors()).toHaveLength(1);
});
it('preserves insertion order', () => {
eq.enqueueError(makeReport('r1'));
eq.enqueueError(makeReport('r2'));
eq.enqueueError(makeReport('r3'));
const ids = eq.getErrors().map(r => r.id);
expect(ids).toEqual(['r1', 'r2', 'r3']);
});
it('caps queue at 10 items, keeping the most recent', () => {
for (let i = 0; i < 12; i++) {
// Each report has a unique title+message to avoid dedup
eq.enqueueError(makeReport(`r${i}`, { title: `T${i}`, message: `M${i}` }));
}
const queue = eq.getErrors();
expect(queue).toHaveLength(10);
// Oldest two should have been dropped
expect(queue[0].id).toBe('r2');
expect(queue[9].id).toBe('r11');
});
});
// ---------------------------------------------------------------------------
// Deduplication
// ---------------------------------------------------------------------------
describe('errorReportQueue — deduplication', () => {
let eq: typeof import('./errorReportQueue');
beforeEach(async () => {
vi.useFakeTimers();
vi.resetModules();
eq = await import('./errorReportQueue');
});
afterEach(() => {
vi.useRealTimers();
});
it('suppresses a duplicate error within the dedup window (2 s)', () => {
const base = makeReport('r1', { title: 'Same', message: 'Same' });
eq.enqueueError(base);
// Same title + message but different ID — should be deduped
eq.enqueueError({ ...base, id: 'r1-dup' });
expect(eq.getErrors()).toHaveLength(1);
});
it('allows the same error again after the dedup window expires', () => {
const base = makeReport('r1', { title: 'Same', message: 'Same' });
eq.enqueueError(base);
// Advance past the 2000 ms DEDUP_WINDOW_MS
vi.advanceTimersByTime(2001);
eq.enqueueError({ ...base, id: 'r1-after' });
expect(eq.getErrors()).toHaveLength(2);
});
it('allows distinct errors (different title/message) within the dedup window', () => {
eq.enqueueError(makeReport('r1', { title: 'A', message: 'a' }));
eq.enqueueError(makeReport('r2', { title: 'B', message: 'b' }));
expect(eq.getErrors()).toHaveLength(2);
});
});
// ---------------------------------------------------------------------------
// subscribe / notify
// ---------------------------------------------------------------------------
describe('errorReportQueue — subscribe', () => {
let eq: typeof import('./errorReportQueue');
beforeEach(async () => {
vi.resetModules();
eq = await import('./errorReportQueue');
});
it('calls subscriber when a report is enqueued', () => {
const cb = vi.fn();
eq.subscribe(cb);
eq.enqueueError(makeReport('r1'));
expect(cb).toHaveBeenCalledTimes(1);
});
it('calls subscriber when a report is dequeued', () => {
eq.enqueueError(makeReport('r1'));
const cb = vi.fn();
eq.subscribe(cb);
eq.dequeueError('r1');
expect(cb).toHaveBeenCalledTimes(1);
});
it('does not call subscriber after unsubscribe', () => {
const cb = vi.fn();
const unsub = eq.subscribe(cb);
unsub();
eq.enqueueError(makeReport('r1'));
expect(cb).not.toHaveBeenCalled();
});
it('supports multiple simultaneous subscribers', () => {
const cb1 = vi.fn();
const cb2 = vi.fn();
eq.subscribe(cb1);
eq.subscribe(cb2);
eq.enqueueError(makeReport('r1'));
expect(cb1).toHaveBeenCalledTimes(1);
expect(cb2).toHaveBeenCalledTimes(1);
});
it('continues notifying other subscribers if one throws', () => {
const bad = vi.fn(() => {
throw new Error('subscriber boom');
});
const good = vi.fn();
eq.subscribe(bad);
eq.subscribe(good);
expect(() => eq.enqueueError(makeReport('r1'))).not.toThrow();
expect(good).toHaveBeenCalledTimes(1);
});
});
// ---------------------------------------------------------------------------
// tagErrorSource
// ---------------------------------------------------------------------------
describe('errorReportQueue — tagErrorSource', () => {
let eq: typeof import('./errorReportQueue');
beforeEach(async () => {
vi.resetModules();
eq = await import('./errorReportQueue');
});
it('updates source on a queued report matched by sentryEvent.event_id', () => {
const report = {
...makeReport('r1'),
source: 'global' as const,
sentryEvent: {
event_id: 'sentry-abc',
timestamp: Date.now() / 1000,
platform: 'javascript',
environment: 'test',
},
};
eq.enqueueError(report);
eq.tagErrorSource('sentry-abc', 'react', '<ErrorBoundary />');
const updated = eq.getErrors().find(r => r.id === 'r1');
expect(updated?.source).toBe('react');
expect(updated?.componentStack).toBe('<ErrorBoundary />');
});
it('is a no-op when event_id is undefined', () => {
eq.enqueueError(makeReport('r1'));
expect(() => eq.tagErrorSource(undefined, 'react')).not.toThrow();
expect(eq.getErrors()[0].source).toBe('manual');
});
it('is a no-op when event_id does not match any queued report', () => {
eq.enqueueError(makeReport('r1'));
expect(() => eq.tagErrorSource('unknown-id', 'react')).not.toThrow();
expect(eq.getErrors()[0].source).toBe('manual');
});
});
// ---------------------------------------------------------------------------
// registerSentrySender / sendToSentry
// ---------------------------------------------------------------------------
describe('errorReportQueue — sendToSentry', () => {
let eq: typeof import('./errorReportQueue');
beforeEach(async () => {
vi.resetModules();
eq = await import('./errorReportQueue');
});
it('returns false when no sender is registered', () => {
const report = {
...makeReport('r1'),
sentryEvent: {
event_id: 'evt-1',
timestamp: Date.now() / 1000,
platform: 'javascript',
environment: 'test',
},
};
eq.enqueueError(report);
expect(eq.sendToSentry(report)).toBe(false);
});
it('returns false when sentryEvent is null', () => {
const sender = vi.fn();
const report = makeReport('r1');
eq.enqueueError(report);
eq.registerSentrySender(sender);
expect(eq.sendToSentry(report)).toBe(false);
expect(sender).not.toHaveBeenCalled();
});
it('calls the registered sender and removes the report on success', () => {
const sender = vi.fn();
const sentryEvent = {
event_id: 'evt-2',
timestamp: Date.now() / 1000,
platform: 'javascript',
environment: 'test',
};
const report = { ...makeReport('r2'), sentryEvent };
eq.enqueueError(report);
eq.registerSentrySender(sender);
const result = eq.sendToSentry(report);
expect(result).toBe(true);
expect(sender).toHaveBeenCalledWith(sentryEvent);
expect(eq.getErrors().find(r => r.id === 'r2')).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// buildManualSentryEvent
// ---------------------------------------------------------------------------
describe('errorReportQueue — buildManualSentryEvent', () => {
let eq: typeof import('./errorReportQueue');
beforeEach(async () => {
vi.resetModules();
eq = await import('./errorReportQueue');
});
it('creates an event with the correct error type and value', () => {
const evt = eq.buildManualSentryEvent({ type: 'TypeError', value: 'bad input' });
expect(evt.exception?.values[0].type).toBe('TypeError');
expect(evt.exception?.values[0].value).toBe('bad input');
});
it('sets platform to javascript', () => {
const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' });
expect(evt.platform).toBe('javascript');
});
it('includes optional tags when provided', () => {
const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' }, { context: 'chat' });
expect(evt.tags).toEqual({ context: 'chat' });
});
it('generates a non-empty event_id', () => {
const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' });
expect(evt.event_id).toBeTruthy();
expect(typeof evt.event_id).toBe('string');
});
it('sets timestamp as a unix seconds value (numeric)', () => {
const before = Date.now() / 1000;
const evt = eq.buildManualSentryEvent({ type: 'Error', value: 'msg' });
const after = Date.now() / 1000;
expect(evt.timestamp).toBeGreaterThanOrEqual(before);
expect(evt.timestamp).toBeLessThanOrEqual(after);
});
});
-239
View File
@@ -1,239 +0,0 @@
/**
* Error Report Queue
*
* Module-level error queue with zero React/Redux/Sentry dependencies.
* Captures errors from all sources (React, global JS, core/runtime services) and
* lets the notification UI subscribe to display them for user opt-in reporting.
*/
import * as Sentry from '@sentry/react';
import { IS_DEV } from '../utils/config';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A stack frame with sensitive fields (vars, source context) stripped. */
interface SafeStackFrame {
filename?: string;
function?: string;
module?: string;
lineno?: number;
colno?: number;
abs_path?: string;
in_app?: boolean;
}
export interface SanitizedSentryEvent {
event_id: string;
timestamp: number;
platform: string;
exception?: {
values: Array<{
type: string;
value: string;
stacktrace?: { frames?: SafeStackFrame[] };
mechanism?: { type: string; handled?: boolean };
}>;
};
contexts?: { os?: object; browser?: object; device?: object };
user?: { id: string };
tags?: Record<string, string>;
environment: string;
}
export interface PendingErrorReport {
id: string;
timestamp: number;
source: 'react' | 'global' | 'manual';
title: string;
message: string;
componentStack?: string;
sentryEvent: SanitizedSentryEvent | null;
originalError?: Error;
}
// ---------------------------------------------------------------------------
// Internal state
// ---------------------------------------------------------------------------
const MAX_QUEUE_SIZE = 10;
let _queue: PendingErrorReport[] = [];
const _subscribers = new Set<() => void>();
// Dedup: track recent error messages to avoid duplicate notifications
const _recentErrors = new Map<string, number>();
const DEDUP_WINDOW_MS = 2000;
function _notify(): void {
for (const cb of _subscribers) {
try {
cb();
} catch {
// Subscriber error — silently ignore to prevent cascading failures
}
}
}
function _dedupeKey(report: Pick<PendingErrorReport, 'title' | 'message'>): string {
return `${report.title}::${report.message}`;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Add an error report to the queue. Notifies all subscribers. */
export function enqueueError(report: PendingErrorReport): void {
const key = _dedupeKey(report);
const now = Date.now();
const lastSeen = _recentErrors.get(key);
if (lastSeen && now - lastSeen < DEDUP_WINDOW_MS) return;
_recentErrors.set(key, now);
// Prune old dedup entries
if (_recentErrors.size > 50) {
for (const [k, t] of _recentErrors) {
if (now - t > DEDUP_WINDOW_MS) _recentErrors.delete(k);
}
}
_queue = [..._queue, report];
if (_queue.length > MAX_QUEUE_SIZE) {
_queue = _queue.slice(_queue.length - MAX_QUEUE_SIZE);
}
_notify();
}
/** Remove an error report by ID (after user acts on it). */
export function dequeueError(id: string): void {
_queue = _queue.filter(r => r.id !== id);
_notify();
}
/** Return current queue snapshot. Compatible with useSyncExternalStore. */
export function getErrors(): PendingErrorReport[] {
return _queue;
}
/** Subscribe to queue changes. Returns unsubscribe function. */
export function subscribe(cb: () => void): () => void {
_subscribers.add(cb);
return () => {
_subscribers.delete(cb);
};
}
/**
* Find a queued error by Sentry event ID and enrich it with React source info.
* Called from the ErrorBoundary's onError callback.
*/
export function tagErrorSource(
eventId: string | undefined,
source: PendingErrorReport['source'],
componentStack?: string
): void {
if (!eventId) return;
const idx = _queue.findIndex(r => r.sentryEvent?.event_id === eventId);
if (idx === -1) return;
const updated = {
..._queue[idx],
source,
componentStack: componentStack ?? _queue[idx].componentStack,
};
_queue = [..._queue.slice(0, idx), updated, ..._queue.slice(idx + 1)];
_notify();
}
// ---------------------------------------------------------------------------
// Sentry bypass — used by the notification to actually send a queued event
// ---------------------------------------------------------------------------
/** Reference to the bypass sender set by analytics.ts during init. */
let _sendViaSentry: ((event: SanitizedSentryEvent) => void) | null = null;
export function registerSentrySender(fn: (event: SanitizedSentryEvent) => void): void {
_sendViaSentry = fn;
}
/** Send a queued error's payload to Sentry and remove from queue. */
export function sendToSentry(report: PendingErrorReport): boolean {
if (!report.sentryEvent || !_sendViaSentry) return false;
_sendViaSentry(report.sentryEvent);
dequeueError(report.id);
return true;
}
// ---------------------------------------------------------------------------
// Sentry active check
// ---------------------------------------------------------------------------
function isSentryActive(): boolean {
try {
const client = Sentry.getClient();
return Boolean(client);
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Build a SanitizedSentryEvent manually (for errors not from Sentry pipeline)
// ---------------------------------------------------------------------------
export function buildManualSentryEvent(
error: { type: string; value: string },
tags?: Record<string, string>
): SanitizedSentryEvent {
return {
event_id: crypto.randomUUID().replace(/-/g, ''),
timestamp: Date.now() / 1000,
platform: 'javascript',
exception: { values: [{ type: error.type, value: error.value }] },
tags,
environment: IS_DEV ? 'development' : 'production',
};
}
// ---------------------------------------------------------------------------
// Dev-mode global listeners
// ---------------------------------------------------------------------------
function initGlobalListeners(): void {
window.addEventListener('error', (event: ErrorEvent) => {
// Skip if Sentry is active — it captures these via globalHandlersIntegration
if (isSentryActive()) return;
const error = event.error instanceof Error ? event.error : new Error(event.message);
enqueueError({
id: crypto.randomUUID(),
timestamp: Date.now(),
source: 'global',
title: error.name || 'Error',
message: error.message || event.message || 'Unknown error',
sentryEvent: null,
originalError: error,
});
});
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
if (isSentryActive()) return;
const reason = event.reason;
const error = reason instanceof Error ? reason : new Error(String(reason));
enqueueError({
id: crypto.randomUUID(),
timestamp: Date.now(),
source: 'global',
title: error.name || 'UnhandledRejection',
message: error.message || String(reason) || 'Unhandled promise rejection',
sentryEvent: null,
originalError: error,
});
});
}
// Register listeners immediately on module load
initGlobalListeners();
+7
View File
@@ -132,3 +132,10 @@ export const MINIMUM_SUPPORTED_APP_VERSION =
export const LATEST_APP_DOWNLOAD_URL =
(import.meta.env.VITE_LATEST_APP_DOWNLOAD_URL as string | undefined)?.trim() ||
'https://github.com/tinyhumansai/openhuman/releases/latest';
/**
* Set `VITE_SENTRY_SMOKE_TEST=true` in one build (or in `.env.local`) to
* fire a one-shot diagnostic event at `initSentry()` time and verify the
* Sentry pipeline end-to-end. Has no effect in normal builds.
*/
export const SENTRY_SMOKE_TEST = import.meta.env.VITE_SENTRY_SMOKE_TEST === 'true';
+10 -13
View File
@@ -1,10 +1,10 @@
import * as Sentry from '@sentry/react';
import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { getCoreStateSnapshot, patchCoreStateSnapshot } from '../lib/coreState/store';
import { consumeLoginToken } from '../services/api/authApi';
import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue';
import {
beginDeepLinkAuthProcessing,
completeDeepLinkAuthProcessing,
@@ -152,26 +152,23 @@ const handleOAuthDeepLink = async (parsed: URL) => {
versionGate.current === 'unknown'
? `OpenHuman could not verify this build against the minimum required for OAuth (${versionGate.minimum}). Install the latest release, then try connecting again.`
: `This OpenHuman build (${versionGate.current}) is older than the minimum required for OAuth (${versionGate.minimum}). Install the latest release, then try connecting again.`;
console.warn(`[DeepLink][oauth:stale-app] ${msg}`);
try {
await openUrl(versionGate.downloadUrl);
} catch (e) {
console.warn('[DeepLink] Could not open latest release URL', e);
}
enqueueError({
id: crypto.randomUUID(),
timestamp: Date.now(),
source: 'manual',
title: 'Update OpenHuman to finish OAuth',
message: msg,
sentryEvent: buildManualSentryEvent(
{ type: 'OAuthStaleAppVersion', value: `${versionGate.current}<${versionGate.minimum}` },
{
Sentry.captureMessage(
`OAuth blocked: stale app version ${versionGate.current}<${versionGate.minimum}`,
{
level: 'warning',
tags: {
component: 'desktopDeepLinkListener',
current: versionGate.current,
minimum: versionGate.minimum,
}
),
});
},
}
);
window.dispatchEvent(
new CustomEvent('oauth:stale-app', {
detail: {
+1
View File
@@ -6,6 +6,7 @@ interface ImportMetaEnv {
readonly VITE_BACKEND_URL?: string;
readonly VITE_SKILLS_GITHUB_REPO?: string;
readonly VITE_SENTRY_DSN?: string;
readonly VITE_SENTRY_SMOKE_TEST?: string;
readonly VITE_BUILD_SHA?: string;
readonly VITE_DEV_JWT_TOKEN?: string;
readonly VITE_DEV_FORCE_ONBOARDING?: string;