PageSourceSearch

https://app.cension.ai/js/sessionRecorder.js

js cension.ai collected 2026-10-01 07:02:19 UTC 16,055 bytes, 405 lines download raw bytes

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.