mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Steven Enamakel
parent
1a30a0ab4d
commit
a084ebf45c
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -56,10 +56,6 @@
|
||||
"libx11-6",
|
||||
"libgdk-pixbuf-2.0-0",
|
||||
"libglib2.0-0"
|
||||
],
|
||||
"args": [
|
||||
"--enable-features=UseOzonePlatform",
|
||||
"--ozone-platform=x11"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
+1
-5
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user