Skip to content

Commit ca581b6

Browse files
adeebshihadehclaude
andcommitted
add sentry session logging
Logs pass/fail for every flash session with console output and device info. Set VITE_SENTRY_DSN to enable. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4e577e1 commit ca581b6

File tree

4 files changed

+278
-8
lines changed

4 files changed

+278
-8
lines changed

.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
VITE_SENTRY_DSN=https://acb8cfad1992fafc3dc90ab1bfa3d07f@o33823.ingest.us.sentry.io/4510604761825280

src/app/Flash.jsx

Lines changed: 123 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useRef, useState } from 'react'
2+
import { addBreadcrumb, setTags, setTag, setContext, captureSessionSummary } from '../utils/telemetry'
23

34
import { FlashManager, StepCode, ErrorCode, DeviceType } from '../utils/manager'
45
import { useImageManager } from '../utils/image'
@@ -38,9 +39,9 @@ function ImagePreloader() {
3839
)
3940
}
4041

41-
// Capture console logs for debug reports
42+
// Capture console logs for debug reports and telemetry
4243
const consoleLogs = []
43-
const MAX_LOGS = 100
44+
const MAX_LOGS = 500
4445
const originalConsole = { log: console.log, warn: console.warn, error: console.error, info: console.info, debug: console.debug }
4546
;['log', 'warn', 'error', 'info', 'debug'].forEach(level => {
4647
console[level] = (...args) => {
@@ -50,6 +51,41 @@ const originalConsole = { log: console.log, warn: console.warn, error: console.e
5051
}
5152
})
5253

54+
// Unique per-page session id for correlating events
55+
const SESSION_ID = (crypto && 'randomUUID' in crypto) ? crypto.randomUUID() : String(Math.random()).slice(2)
56+
57+
// Helper for building environment metadata
58+
function buildEnvMeta() {
59+
const ua = navigator.userAgent
60+
let os = 'Unknown'
61+
if (ua.includes('Windows NT 10.0')) os = 'Windows 10/11'
62+
else if (ua.includes('Windows NT 6.3')) os = 'Windows 8.1'
63+
else if (ua.includes('Windows NT 6.2')) os = 'Windows 8'
64+
else if (ua.includes('Windows NT 6.1')) os = 'Windows 7'
65+
else if (ua.includes('Mac OS X')) {
66+
const match = ua.match(/Mac OS X (\d+[._]\d+[._]?\d*)/)
67+
os = match ? `macOS ${match[1].replace(/_/g, '.')}` : 'macOS'
68+
} else if (ua.includes('Linux')) {
69+
os = 'Linux'
70+
if (ua.includes('Ubuntu')) os += ' (Ubuntu)'
71+
else if (ua.includes('Fedora')) os += ' (Fedora)'
72+
else if (ua.includes('Debian')) os += ' (Debian)'
73+
} else if (ua.includes('CrOS')) os = 'ChromeOS'
74+
75+
const sandboxHints = []
76+
if (ua.includes('snap')) sandboxHints.push('Snap')
77+
if (ua.includes('Flatpak')) sandboxHints.push('Flatpak')
78+
if (navigator.userAgentData?.brands?.some(b => b.brand.includes('snap'))) sandboxHints.push('Snap')
79+
80+
return {
81+
os,
82+
sandbox: sandboxHints.length ? sandboxHints.join(', ') : 'None detected',
83+
browser: navigator.userAgent,
84+
url: window.location.href,
85+
version: import.meta.env.VITE_PUBLIC_GIT_SHA || 'dev',
86+
}
87+
}
88+
5389
// Debug info component for error reporting
5490
function DebugInfo({ error, step, selectedDevice, serial, message, onClose }) {
5591
const [copied, setCopied] = useState(false)
@@ -622,6 +658,7 @@ export default function Flash() {
622658
const [serial, setSerial] = useState(null)
623659
const [selectedDevice, setSelectedDevice] = useState(null)
624660
const [wizardScreen, setWizardScreen] = useState('landing') // 'landing', 'device', 'zadig', 'connect', 'unbind', 'webusb', 'flash'
661+
const reportSentRef = useRef(false)
625662

626663
const qdlManager = useRef(null)
627664
const imageManager = useImageManager()
@@ -638,12 +675,34 @@ export default function Flash() {
638675
.then((programmer) => {
639676
// Create QDL manager with callbacks that update React state
640677
qdlManager.current = new FlashManager(programmer, {
641-
onStepChange: setStep,
642-
onMessageChange: setMessage,
643-
onProgressChange: setProgress,
644-
onErrorChange: setError,
645-
onConnectionChange: setConnected,
646-
onSerialChange: setSerial
678+
onStepChange: (s) => {
679+
setStep(s)
680+
addBreadcrumb({ category: 'flash', message: `step:${s}`, level: 'info', data: { step: s } })
681+
setTag('last_step', String(s))
682+
},
683+
onMessageChange: (m) => {
684+
setMessage(m)
685+
if (m) addBreadcrumb({ category: 'flash', message: m, level: 'info' })
686+
},
687+
onProgressChange: (p) => {
688+
setProgress(p)
689+
},
690+
onErrorChange: (e) => {
691+
setError(e)
692+
if (e !== ErrorCode.NONE) {
693+
addBreadcrumb({ category: 'flash', message: 'error', level: 'error', data: { errorCode: e } })
694+
setTag('error_code', String(e))
695+
}
696+
},
697+
onConnectionChange: (c) => {
698+
setConnected(c)
699+
addBreadcrumb({ category: 'flash', message: c ? 'connected' : 'disconnected', level: c ? 'info' : 'warning' })
700+
},
701+
onSerialChange: (sn) => {
702+
setSerial(sn)
703+
// Avoid tagging the raw serial; keep in context only
704+
setContext('device', { serial_present: !!sn })
705+
}
647706
})
648707

649708
// Initialize the manager
@@ -655,6 +714,62 @@ export default function Flash() {
655714
})
656715
}, [config, imageManager.current])
657716

717+
// Telemetry: set static tags/context once
718+
useEffect(() => {
719+
setTags({ session_id: SESSION_ID })
720+
setContext('env', buildEnvMeta())
721+
}, [])
722+
723+
// Telemetry: tag device selection
724+
useEffect(() => {
725+
if (selectedDevice) {
726+
setTag('device_type', selectedDevice)
727+
addBreadcrumb({ category: 'flash', message: `device:${selectedDevice}`, level: 'info' })
728+
}
729+
}, [selectedDevice])
730+
731+
// Telemetry: wizard screen transitions
732+
useEffect(() => {
733+
if (wizardScreen) addBreadcrumb({ category: 'wizard', message: wizardScreen, level: 'info' })
734+
}, [wizardScreen])
735+
736+
// Helper to send a single pass/fail summary
737+
function sendSessionSummary(result) {
738+
if (reportSentRef.current) return
739+
reportSentRef.current = true
740+
const meta = {
741+
...buildEnvMeta(),
742+
selectedDevice,
743+
connected,
744+
serial_present: !!serial,
745+
step,
746+
message,
747+
}
748+
const tail = consoleLogs.slice(-200)
749+
captureSessionSummary({
750+
sessionId: SESSION_ID,
751+
result,
752+
errorCode: error,
753+
step,
754+
meta,
755+
consoleTail: tail,
756+
})
757+
}
758+
759+
// Send fail on first error
760+
useEffect(() => {
761+
if (error !== ErrorCode.NONE && !reportSentRef.current) {
762+
sendSessionSummary('fail')
763+
}
764+
}, [error])
765+
766+
// Send pass when done without error
767+
useEffect(() => {
768+
if (step === StepCode.DONE && error === ErrorCode.NONE && !reportSentRef.current) {
769+
sendSessionSummary('pass')
770+
}
771+
}, [step, error])
772+
658773
// Transition to flash screen when connected
659774
useEffect(() => {
660775
if (connected && wizardScreen === 'webusb') {

src/main.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react'
22
import ReactDOM from 'react-dom/client'
3+
import { initSentry } from './utils/telemetry'
34

45
import '@fontsource-variable/inter'
56
import '@fontsource-variable/jetbrains-mono'
@@ -15,6 +16,9 @@ async function loadFonts() {
1516
])
1617
}
1718

19+
// Initialize telemetry (no-op if DSN unset)
20+
initSentry()
21+
1822
loadFonts().then(() => {
1923
ReactDOM.createRoot(document.getElementById('root')).render(
2024
<React.StrictMode>

src/utils/telemetry.js

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Lightweight Sentry integration (CDN-based, no-op if DSN unset)
2+
// - Loads Sentry browser bundle at runtime if a DSN is provided
3+
// - Exposes helpers to set tags, add breadcrumbs, and send a final session event
4+
5+
let sentryInited = false
6+
let sentryInitPromise = null
7+
8+
function loadScript(src) {
9+
return new Promise((resolve, reject) => {
10+
const script = document.createElement('script')
11+
script.src = src
12+
script.crossOrigin = 'anonymous'
13+
script.async = true
14+
script.onload = () => resolve()
15+
script.onerror = () => reject(new Error(`Failed to load ${src}`))
16+
document.head.appendChild(script)
17+
})
18+
}
19+
20+
// Best-effort CDN load for Sentry + Replay. If already present, skips.
21+
async function ensureSentryBundle() {
22+
if (window.Sentry) return
23+
// Use a stable recent v7 bundle that includes tracing + replay
24+
// If you want to pin a newer version later, update this URL.
25+
const url = 'https://browser.sentry-cdn.com/7.114.0/bundle.tracing.replay.min.js'
26+
await loadScript(url)
27+
}
28+
29+
/** Initialize Sentry if `VITE_SENTRY_DSN` is provided. No-op otherwise. */
30+
export async function initSentry(options = {}) {
31+
if (sentryInited || sentryInitPromise) return sentryInitPromise
32+
33+
const dsn = import.meta.env.VITE_SENTRY_DSN
34+
if (!dsn) {
35+
// No DSN configured; keep helpers as no-ops
36+
sentryInited = false
37+
sentryInitPromise = Promise.resolve(false)
38+
return sentryInitPromise
39+
}
40+
41+
sentryInitPromise = (async () => {
42+
try {
43+
await ensureSentryBundle()
44+
if (!window.Sentry) return false
45+
46+
const release = import.meta.env.VITE_PUBLIC_GIT_SHA || 'dev'
47+
const environment = import.meta.env.MODE || 'development'
48+
49+
// Default to replay only on error to keep costs down; can be tuned.
50+
window.Sentry.init({
51+
dsn,
52+
release,
53+
environment,
54+
// Breadcrumbs already capture console by default; keep it.
55+
// Limit traces by default; you can increase later if desired.
56+
tracesSampleRate: options.tracesSampleRate ?? 0.0,
57+
replaysSessionSampleRate: options.replaysSessionSampleRate ?? 0.0,
58+
replaysOnErrorSampleRate: options.replaysOnErrorSampleRate ?? 1.0,
59+
maxBreadcrumbs: options.maxBreadcrumbs ?? 100,
60+
integrations: [new window.Sentry.Replay({
61+
maskAllText: true,
62+
blockAllMedia: true,
63+
})],
64+
})
65+
66+
sentryInited = true
67+
return true
68+
} catch (e) {
69+
// Swallow init errors to avoid breaking the app
70+
// eslint-disable-next-line no-console
71+
console.warn('[Sentry] init failed:', e)
72+
sentryInited = false
73+
return false
74+
}
75+
})()
76+
77+
return sentryInitPromise
78+
}
79+
80+
// Safe no-op wrappers if Sentry isn’t available
81+
function withSentry(cb) {
82+
if (!sentryInited || !window.Sentry) return
83+
try { cb(window.Sentry) } catch { /* no-op */ }
84+
}
85+
86+
export function setTags(tags = {}) {
87+
withSentry((S) => S.setTags(tags))
88+
}
89+
90+
export function setTag(key, value) {
91+
withSentry((S) => S.setTag(key, value))
92+
}
93+
94+
export function setContext(key, context) {
95+
withSentry((S) => S.setContext(key, context))
96+
}
97+
98+
export function addBreadcrumb({ category, message, level = 'info', data }) {
99+
withSentry((S) => S.addBreadcrumb({ category, message, level, data }))
100+
}
101+
102+
/**
103+
* Send a compact session summary event with pass/fail and optional console tail.
104+
* To keep payloads small, logs are placed in `extra.console_tail` and truncated.
105+
*/
106+
export function captureSessionSummary({
107+
sessionId,
108+
result, // 'pass' | 'fail' | 'abort'
109+
errorCode,
110+
step,
111+
meta = {},
112+
consoleTail = [], // Array<{ time, level, message }>
113+
}) {
114+
withSentry((S) => {
115+
const level = result === 'pass' ? 'info' : 'error'
116+
117+
// Attach tags for easier filtering
118+
S.setTags({
119+
session_id: sessionId,
120+
result,
121+
error_code: typeof errorCode === 'number' ? String(errorCode) : (errorCode || 'none'),
122+
last_step: typeof step === 'number' ? String(step) : (step || 'unknown'),
123+
})
124+
125+
// Try to attach full logs if the SDK supports attachments (v7+ feature; guarded)
126+
let usedAttachment = false
127+
try {
128+
const hasAddAttachment = !!S.addAttachment
129+
if (hasAddAttachment && consoleTail && consoleTail.length) {
130+
const blob = new Blob([JSON.stringify(consoleTail)], { type: 'application/json' })
131+
// Some SDKs attach to current scope and include on next captured event
132+
S.addAttachment({ filename: 'console_tail.json', data: blob, contentType: 'application/json' })
133+
usedAttachment = true
134+
}
135+
} catch { /* ignore */ }
136+
137+
// Always include a compact tail in extras as a fallback
138+
const safeTail = (consoleTail || []).slice(-200)
139+
S.captureMessage('flash_session', {
140+
level,
141+
tags: undefined, // tags set via setTags above
142+
extra: {
143+
usedAttachment,
144+
meta,
145+
console_tail: safeTail,
146+
},
147+
})
148+
})
149+
}
150+

0 commit comments

Comments
 (0)