1// Session Recorder â localhost-only manual-test capture helper. 2// 3// Hooks console.* methods, window.fetch, and XMLHttpRequest so the user can hit 4// "Start" from the floating widget, run a manual prompt (V2-MAN-018+), then hit 5// "Stop" to download a Markdown bundle they paste straight into the prompt 6// matrix. Navigation across the SPA does NOT interrupt the recording â state 7// lives on `window.__sessionRecorder` so it survives Blazor re-renders. 8// 9// When configureApiBase() has been called, recording also opens a matching 10// Base.API log session (debug/generation/...) via X-Cension-Session-Recording. 11 12(() => { 13 if (typeof window === 'undefined') return; 14 if (window.__sessionRecorder && window.__sessionRecorder.__installed) { 15 return; 16 } 17 18 const MAX_ENTRIES = 20000; 19 const MAX_PAYLOAD_CHARS = 8000; 20 const RECORDING_HEADER = 'X-Cension-Session-Recording'; 21 22 const state = { 23 recording: false, 24 paused: false, 25 label: '', 26 startedAt: null, 27 entries: [], 28 apiBase: null, 29 apiSessionId: null, 30 }; 31 32 const original = { 33 log: console.log.bind(console), 34 info: console.info.bind(console), 35 warn: console.warn.bind(console), 36 error: console.error.bind(console), 37 debug: console.debug ? console.debug.bind(console) : console.log.bind(console), 38 fetch: window.fetch ? window.fetch.bind(window) : null, 39 }; 40 41 const truncate = (value) => { 42 if (value === undefined) return 'undefined'; 43 if (value === null) return 'null'; 44 let s; 45 if (typeof value === 'string') { 46 s = value; 47 } else { 48 try { s = JSON.stringify(value); } 49 catch { s = String(value); } 50 } 51 if (s.length > MAX_PAYLOAD_CHARS) s = s.substring(0, MAX_PAYLOAD_CHARS) + 'â¦[truncated]'; 52 return s; 53 }; 54 55 const push = (entry) => { 56 if (!state.recording || state.paused) return; 57 if (state.entries.length >= MAX_ENTRIES) { 58 state.entries.splice(0, Math.floor(MAX_ENTRIES * 0.1)); 59 } 60 state.entries.push(Object.assign({ ts: new Date().toISOString() }, entry)); 61 }; 62 63 const normalizeApiBase = (baseUrl) => { 64 if (!baseUrl) return null; 65 const trimmed = String(baseUrl).trim(); 66 if (!trimmed) return null; 67 return trimmed.endsWith('/') ? trimmed : `${trimmed}/`; 68 }; 69 70 const mergeRecordingHeader = (headers, sessionId) => { 71 if (!sessionId) return headers; 72 if (headers instanceof Headers) { 73 const next = new Headers(headers); 74 next.set(RECORDING_HEADER, sessionId); 75 return next; 76 } 77 return Object.assign({}, headers || {}, { [RECORDING_HEADER]: sessionId }); 78 }; 79 80 const wrapConsole = (level) => (...args) => { 81 try { 82 const text = args.map(truncate).join(' '); 83 push({ kind: 'console', level, text }); 84 } catch { /* never let recording break the host page */ } 85 return original[level](...args); 86 }; 87 88 console.log = wrapConsole('log'); 89 console.info = wrapConsole('info'); 90 console.warn = wrapConsole('warn'); 91 console.error = wrapConsole('error'); 92 console.debug = wrapConsole('debug'); 93 94 if (original.fetch) { 95 window.fetch = async function (...args) { 96 const startedAt = performance.now(); 97 const input = args[0]; 98 let init = args[1] || {}; 99 const method = (init.method || (typeof input !== 'string' && input.method) || 'GET').toUpperCase(); 100 const url = typeof input === 'string' ? input : (input && input.url) || String(input); 101 102 if (state.apiSessionId) { 103 init = Object.assign({}, init, { 104 headers: mergeRecordingHeader(init.headers, state.apiSessionId), 105 }); 106 args[1] = init; 107 } 108 109 let status = null; 110 let ok = false; 111 let errorText = null; 112 try { 113 const response = await original.fetch.apply(this, args); 114 status = response.status; 115 ok = response.ok; 116 return response; 117 } catch (err) { 118 errorText = (err && err.message) || String(err); 119 throw err; 120 } finally { 121 const durationMs = Math.round(performance.now() - startedAt); 122 push({ 123 kind: 'fetch', 124 method, 125 url, 126 status, 127 ok, 128 durationMs, 129 error: errorText, 130 }); 131 } 132 }; 133 } 134 135 const OriginalXHR = window.XMLHttpRequest; 136 if (OriginalXHR && !OriginalXHR.__sessionRecorderWrapped) { 137 const wrappedOpen = OriginalXHR.prototype.open; 138 const wrappedSend = OriginalXHR.prototype.send; 139 const wrappedSetRequestHeader = OriginalXHR.prototype.setRequestHeader; 140 141 OriginalXHR.prototype.open = function (method, url, ...rest) { 142 this.__sr_method = (method || 'GET').toUpperCase(); 143 this.__sr_url = url; 144 this.__sr_headers = {}; 145 return wrappedOpen.call(this, method, url, ...rest); 146 }; 147 148 OriginalXHR.prototype.setRequestHeader = function (name, value) { 149 this.__sr_headers = this.__sr_headers || {}; 150 this.__sr_headers[String(name).toLowerCase()] = value; 151 return wrappedSetRequestHeader.call(this, name, value); 152 }; 153 154 OriginalXHR.prototype.send = function (...args) { 155 const startedAt = performance.now(); 156 const xhr = this; 157 158 if (state.apiSessionId) { 159 const hasHeader = xhr.__sr_headers && xhr.__sr_headers[RECORDING_HEADER.toLowerCase()]; 160 if (!hasHeader) { 161 wrappedSetRequestHeader.call(xhr, RECORDING_HEADER, state.apiSessionId); 162 } 163 } 164 165 const finalize = () => { 166 push({ 167 kind: 'xhr', 168 method: xhr.__sr_method || 'GET', 169 url: xhr.__sr_url || '', 170 status: xhr.status, 171 ok: xhr.status >= 200 && xhr.status < 400, 172 durationMs: Math.round(performance.now() - startedAt), 173 }); 174 }; 175 xhr.addEventListener('loadend', finalize, { once: true }); 176 return wrappedSend.apply(this, args); 177 }; 178 179 OriginalXHR.__sessionRecorderWrapped = true; 180 } 181 182 const startApiSession = async (label) => {
183 if (!state.apiBase || !original.fetch) return null; 184 185 try { 186 const res = await original.fetch(`${state.apiBase}api/generation-logs/sessions`, { 187 method: 'POST', 188 headers: { 'Content-Type': 'application/json' }, 189 body: JSON.stringify({ label }), 190 }); 191 if (!res.ok) { 192 original.warn('[SessionRecorder] Failed to start API log session', res.status); 193 return null; 194 } 195 const data = await res.json(); 196 return data && data.sessionId ? data.sessionId : null; 197 } catch (err) { 198 original.warn('[SessionRecorder] API log session start error', err); 199 return null; 200 } 201 }; 202 203 const stopApiSession = async () => { 204 const sessionId = state.apiSessionId; 205 state.apiSessionId = null; 206 if (!sessionId || !state.apiBase || !original.fetch) return null; 207 208 try { 209 const dlRes = await original.fetch(`${state.apiBase}api/generation-logs/sessions/${sessionId}/download`, { 210 headers: mergeRecordingHeader(undefined, sessionId), 211 }); 212 if (dlRes.ok) { 213 const blob = await dlRes.blob(); 214 const stamp = (state.startedAt || new Date()).toISOString().replace(/[:.]/g, '-'); 215 const slug = (state.label || 'session').replace(/[^a-z0-9\-_]+/gi, '_').toLowerCase(); 216 triggerBlobDownload(`base.api_${slug}_${stamp}.log`, blob); 217 } else { 218 original.warn('[SessionRecorder] Failed to download API log', dlRes.status); 219 } 220 221 await original.fetch(`${state.apiBase}api/generation-logs/sessions/${sessionId}/stop`, { 222 method: 'POST', 223 headers: mergeRecordingHeader({ 'Content-Type': 'application/json' }, sessionId), 224 }); 225 } catch (err) { 226 original.warn('[SessionRecorder] API log session stop error', err); 227 } 228 229 return sessionId; 230 }; 231 232 const api = { 233 __installed: true, 234 configureApiBase(baseUrl) { 235 state.apiBase = normalizeApiBase(baseUrl); 236 }, 237 async start(label) { 238 state.label = (label || '').trim() || `console-${new Date().toISOString().replace(/[:.]/g, '-')}`; 239 state.startedAt = new Date(); 240 state.entries = []; 241 state.recording = true; 242 state.paused = false; 243 state.apiSessionId = null; 244 245 if (state.apiBase) { 246 state.apiSessionId = await startApiSession(state.label); 247 } 248 249 push({ kind: 'meta', text: `RECORDING_STARTED label="${state.label}" url="${location.href}" apiSessionId="${state.apiSessionId || 'none'}"` }); 250 }, 251 pause() { 252 if (!state.recording) return; 253 state.paused = true; 254 const wasPaused = state.paused; 255 state.paused = false; 256 push({ kind: 'meta', text: 'RECORDING_PAUSED' }); 257 state.paused = wasPaused; 258 }, 259 resume() { 260 if (!state.recording) return; 261 state.paused = false; 262 push({ kind: 'meta', text: 'RECORDING_RESUMED' }); 263 }, 264 note(text) { 265 push({ kind: 'note', text: truncate(text) }); 266 }, 267 status() { 268 return { 269 recording: state.recording, 270 paused: state.paused, 271 label: state.label, 272 count: state.entries.length, 273 startedAt: state.startedAt ? state.startedAt.toISOString() : null, 274 elapsedMs: state.startedAt ? Date.now() - state.startedAt.getTime() : 0, 275 apiSessionId: state.apiSessionId, 276 }; 277 }, 278 async stopAndDownload() { 279 if (!state.recording) return null; 280 state.recording = false; 281 state.paused = false; 282 push({ kind: 'meta', text: 'RECORDING_STOPPED' }); 283 284 const markdown = buildMarkdown(state.label, state.startedAt, state.entries); 285 const slug = (state.label || 'session').replace(/[^a-z0-9\-_]+/gi, '_').toLowerCase(); 286 const stamp = (state.startedAt || new Date()).toISOString().replace(/[:.]/g, '-'); 287 const filename = `${slug}_${stamp}.md`; 288 triggerDownload(filename, markdown); 289 290 await stopApiSession(); 291 292 return { filename, count: state.entries.length }; 293 }, 294 snapshot() { 295 const markdown = buildMarkdown(state.label, state.startedAt, state.entries); 296 const slug = (state.label || 'session').replace(/[^a-z0-9\-_]+/gi, '_').toLowerCase(); 297 const stamp = new Date().toISOString().replace(/[:.]/g, '-'); 298 const filename = `${slug}_snapshot_${stamp}.md`; 299 triggerDownload(filename, markdown); 300 return { filename, count: state.entries.length }; 301 }, 302 }; 303 304 function fmtLevel(level) { 305 switch (level) { 306 case 'error': return 'ERR '; 307 case 'warn': return 'WARN'; 308 case 'info': return 'INFO'; 309 case 'debug': return 'DBG '; 310 default: return 'LOG '; 311 } 312 } 313 314 function buildMarkdown(label, startedAt, entries) { 315 const lines = []; 316 lines.push(`# Session Recording â ${label || 'session'}`); 317 lines.push(''); 318 lines.push(`- Started: ${startedAt ? startedAt.toISOString() : 'n/a'}`); 319 lines.push(`- Ended: ${new Date().toISOString()}`); 320 lines.push(`- Entries: ${entries.length}`); 321 lines.push(`- URL at start: ${location.href}`); 322 lines.push(`- User agent: ${navigator.userAgent}`); 323 lines.push(''); 324 325 const network = entries.filter(e => e.kind === 'fetch' || e.kind === 'xhr'); 326 if (network.length > 0) { 327 lines.push('## Network summary'); 328 lines.push(''); 329 lines.push('| ts | method | status | ms | url |'); 330 lines.push('|----|--------|--------|----|-----|'); 331 for (const n of network) { 332 const status = n.error ? `ERR (${n.error})` : (n.status ?? '-'); 333 lines.push(`| ${n.ts.substring(11, 23)} | ${n.method} | ${status} | ${n.durationMs ?? '-'} | ${n.url} |`); 334 } 335 lines.push(''); 336 } 337 338 const draftLines = entries.filter(e => e.kind === 'console' && typeof e.text === 'string' && e.text.includes('[DRAFT]')); 339 if (draftLines.length > 0) { 340 lines.push('## [DRAFT] traces'); 341 lines.push(''); 342 lines.push('```'); 343 for (const d of draftLines) lines.push(`${d.ts.substring(11, 23)} ${fmtLevel(d.level)} ${d.text}`); 344 lines.push('```'); 345 lines.push(''); 346 } 347 348 lines.push('## Full timeline'); 349 lines.push(''); 350 lines.push('```'); 351 for (const e of entries) { 352 const ts = e.ts.substring(11, 23); 353 if (e.kind === 'console') { 354 lines.push(`${ts} ${fmtLevel(e.level)} ${e.text}`); 355 } else if (e.kind === 'fetch' || e.kind === 'xhr') { 356 const status = e.error ? `ERR(${e.error})` : (e.status ?? '-'); 357 lines.push(`${ts} NET ${e.kind.toUpperCase()} ${e.method} ${e.url} â ${status} (${e.durationMs ?? '-'}ms)`); 358 } else if (e.kind === 'note') { 359 lines.push(`${ts} NOTE ${e.text}`); 360 } else if (e.kind === 'meta') { 361 lines.push(`${ts} META ${e.text}`); 362 } else { 363 lines.push(`${ts} ??? ${JSON.stringify(e)}`); 364 } 365 } 366 lines.push('```'); 367 lines.push(''); 368 return lines.join('\n'); 369 } 370 371 function triggerDownload(filename, contents) { 372 try { 373 const blob = new Blob([contents], { type: 'text/markdown;charset=utf-8' }); 374 triggerBlobDownload(filename, blob); 375 } catch (e) { 376 original.error('[SessionRecorder] Failed to download', e); 377 } 378 } 379 380 function triggerBlobDownload(filename, blob) { 381 try { 382 const url = URL.createObjectURL(blob); 383 const a = document.createElement('a'); 384 a.href = url; 385 a.download = filename; 386 document.body.appendChild(a); 387 a.click(); 388 document.body.removeChild(a); 389 setTimeout(() => URL.revokeObjectURL(url), 60_000); 390 } catch (e) { 391 original.error('[SessionRecorder] Failed to download blob', e); 392 } 393 } 394 395 window.__sessionRecorder = api; 396})(); 397 398export function configureApiBase(baseUrl) { return window.__sessionRecorder?.configureApiBase(baseUrl); } 399export async function start(label) { return window.__sessionRecorder?.start(label); } 400export function pause() { return window.__sessionRecorder?.pause(); } 401export function resume() { return window.__sessionRecorder?.resume(); } 402export async function stopAndDownload() { return window.__sessionRecorder?.stopAndDownload(); } 403export function snapshot() { return window.__sessionRecorder?.snapshot(); } 404export function status() { return window.__sessionRecorder?.status() ?? { recording: false, paused: false, count: 0, elapsedMs: 0 }; } 405export function note(text) { return window.__sessionRecorder?.note(text); }
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.