Files
bolt-diy/app/utils/shell.ts
Stijnus 36f1b9c52d feat: comprehensive debug logging system with capture and download
Add a robust debug logging system that captures application state, user interactions, and system diagnostics for enhanced troubleshooting and development experience.

##  Features Added

### 🔍 **Multi-Source Data Capture**
- **Console Logging**: Captures all console.log, console.warn, console.error
- **Error Handling**: Intercepts JavaScript errors and unhandled promise rejections
- **Network Monitoring**: Tracks all fetch requests with timing and status
- **User Actions**: Records user interactions and UI events
- **Terminal Activity**: Captures shell input/output with ANSI cleaning
- **Performance Metrics**: Memory usage, page load times, paint timing

### 📊 **System Information Collection**
- Platform detection (macOS, Windows, Linux)
- Browser and viewport information
- Git repository status (branch, commit, dirty state)
- Application state (model, provider, workbench view)
- Performance and memory statistics

### 🎯 **User Interface Integration**
- **Avatar Dropdown**: "Download Debug Log" option with download icon
- **Header Actions**: "Debug Log" button alongside existing "Report Bug"
- **One-Click Download**: Generates comprehensive debug reports
- **Error Handling**: Graceful degradation with user feedback

### 🔧 **Technical Implementation**
- **Circular Buffers**: Memory-efficient storage with fixed capacity (1K entries)
- **Lazy Loading**: Zero performance impact when disabled (default state)
- **Debouncing**: Terminal logs debounced at 100ms to prevent spam
- **JSON Safe**: Circular reference protection and depth limiting
- **Async Operations**: Non-blocking debug operations

### 📁 **Files Modified**
- `app/utils/debugLogger.ts` (1,284 lines) - Core debug logging utility
- `app/utils/logger.ts` - Integration with existing logging system
- `app/utils/shell.ts` - Terminal activity capture
- `app/components/@settings/core/AvatarDropdown.tsx` - UI integration
- `app/components/header/HeaderActionButtons.client.tsx` - Header button
- `app/root.tsx` - Initialization and setup
- `app/routes/api.git-info.ts` - Git information endpoint

## 🚀 **Benefits**

- **Enhanced Debugging**: Comprehensive data collection for issue reproduction
- **Performance Monitoring**: Built-in performance tracking and memory analysis
- **User Support**: Easy debug log generation for support tickets
- **Developer Experience**: Rich debugging data without performance penalty
- **Production Ready**: Opt-in system with zero impact on regular users

## 🔒 **Security & Privacy**

- Client-side only operation (no server transmission)
- User-controlled data collection and export
- No sensitive information captured automatically
- Manual opt-in required for debug mode activation

## 📈 **Performance Impact**

- **Disabled by Default**: No performance impact for regular users
- **Lazy Initialization**: Components loaded only when needed
- **Memory Bounded**: Fixed-size buffers prevent memory leaks
- **Non-Blocking**: All operations are asynchronous
- **Efficient Storage**: Circular buffers with automatic cleanup

## 🔄 **Integration Points**

- Seamlessly integrates with existing `logger` utility
- Compatible with current shell/terminal implementation
- Works with existing error handling patterns
- Maintains backward compatibility

This implementation provides developers and users with powerful debugging capabilities while maintaining excellent performance and user experience.
2025-09-07 01:14:29 +02:00

385 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { WebContainer, WebContainerProcess } from '@webcontainer/api';
import type { ITerminal } from '~/types/terminal';
import { withResolvers } from './promises';
import { atom } from 'nanostores';
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
export async function newShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
const args: string[] = [];
// we spawn a JSH process with a fallback cols and rows in case the process is not attached yet to a visible terminal
const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], {
terminal: {
cols: terminal.cols ?? 80,
rows: terminal.rows ?? 15,
},
});
const input = process.input.getWriter();
const output = process.output;
const jshReady = withResolvers<void>();
let isInteractive = false;
output.pipeTo(
new WritableStream({
write(data) {
if (!isInteractive) {
const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || [];
if (osc === 'interactive') {
// wait until we see the interactive OSC
isInteractive = true;
jshReady.resolve();
}
}
terminal.write(data);
// Capture terminal output for debugging
try {
import('~/utils/debugLogger')
.then(({ captureTerminalLog }) => {
// Clean the data by removing ANSI escape sequences for logging
const cleanData = data.replace(/\x1b\[[0-9;]*[mG]/g, '').trim();
if (cleanData) {
captureTerminalLog(cleanData, 'output');
}
})
.catch(() => {
// Ignore if debug logger is not available
});
} catch {
// Ignore errors in debug logging
}
},
}),
);
terminal.onData((data) => {
// console.log('terminal onData', { data, isInteractive });
if (isInteractive) {
input.write(data);
// Capture terminal input for debugging
try {
import('~/utils/debugLogger')
.then(({ captureTerminalLog }) => {
// Clean the data and check if it's a command (not just cursor movement)
const cleanData = data.replace(/\x1b\[[0-9;]*[A-Z]/g, '').trim();
if (cleanData && cleanData !== '\r' && cleanData !== '\n') {
captureTerminalLog(cleanData, 'input');
}
})
.catch(() => {
// Ignore if debug logger is not available
});
} catch {
// Ignore errors in debug logging
}
}
});
await jshReady.promise;
return process;
}
export type ExecutionResult = { output: string; exitCode: number } | undefined;
export class BoltShell {
#initialized: (() => void) | undefined;
#readyPromise: Promise<void>;
#webcontainer: WebContainer | undefined;
#terminal: ITerminal | undefined;
#process: WebContainerProcess | undefined;
executionState = atom<
{ sessionId: string; active: boolean; executionPrms?: Promise<any>; abort?: () => void } | undefined
>();
#outputStream: ReadableStreamDefaultReader<string> | undefined;
#shellInputStream: WritableStreamDefaultWriter<string> | undefined;
constructor() {
this.#readyPromise = new Promise((resolve) => {
this.#initialized = resolve;
});
}
ready() {
return this.#readyPromise;
}
async init(webcontainer: WebContainer, terminal: ITerminal) {
this.#webcontainer = webcontainer;
this.#terminal = terminal;
// Use all three streams from tee: one for terminal, one for command execution, one for Expo URL detection
const { process, commandStream, expoUrlStream } = await this.newBoltShellProcess(webcontainer, terminal);
this.#process = process;
this.#outputStream = commandStream.getReader();
// Start background Expo URL watcher immediately
this._watchExpoUrlInBackground(expoUrlStream);
await this.waitTillOscCode('interactive');
this.#initialized?.();
}
async newBoltShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
const args: string[] = [];
const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], {
terminal: {
cols: terminal.cols ?? 80,
rows: terminal.rows ?? 15,
},
});
const input = process.input.getWriter();
this.#shellInputStream = input;
// Tee the output so we can have three independent readers
const [streamA, streamB] = process.output.tee();
const [streamC, streamD] = streamB.tee();
const jshReady = withResolvers<void>();
let isInteractive = false;
streamA.pipeTo(
new WritableStream({
write(data) {
if (!isInteractive) {
const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || [];
if (osc === 'interactive') {
isInteractive = true;
jshReady.resolve();
}
}
terminal.write(data);
},
}),
);
terminal.onData((data) => {
if (isInteractive) {
input.write(data);
}
});
await jshReady.promise;
// Return all streams for use in init
return { process, terminalStream: streamA, commandStream: streamC, expoUrlStream: streamD };
}
// Dedicated background watcher for Expo URL
private async _watchExpoUrlInBackground(stream: ReadableStream<string>) {
const reader = stream.getReader();
let buffer = '';
const expoUrlRegex = /(exp:\/\/[^\s]+)/;
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += value || '';
const expoUrlMatch = buffer.match(expoUrlRegex);
if (expoUrlMatch) {
const cleanUrl = expoUrlMatch[1]
.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '')
.replace(/[^\x20-\x7E]+$/g, '');
expoUrlAtom.set(cleanUrl);
buffer = buffer.slice(buffer.indexOf(expoUrlMatch[1]) + expoUrlMatch[1].length);
}
if (buffer.length > 2048) {
buffer = buffer.slice(-2048);
}
}
}
get terminal() {
return this.#terminal;
}
get process() {
return this.#process;
}
async executeCommand(sessionId: string, command: string, abort?: () => void): Promise<ExecutionResult> {
if (!this.process || !this.terminal) {
return undefined;
}
const state = this.executionState.get();
if (state?.active && state.abort) {
state.abort();
}
/*
* interrupt the current execution
* this.#shellInputStream?.write('\x03');
*/
this.terminal.input('\x03');
await this.waitTillOscCode('prompt');
if (state && state.executionPrms) {
await state.executionPrms;
}
//start a new execution
this.terminal.input(command.trim() + '\n');
//wait for the execution to finish
const executionPromise = this.getCurrentExecutionResult();
this.executionState.set({ sessionId, active: true, executionPrms: executionPromise, abort });
const resp = await executionPromise;
this.executionState.set({ sessionId, active: false });
if (resp) {
try {
resp.output = cleanTerminalOutput(resp.output);
} catch (error) {
console.log('failed to format terminal output', error);
}
}
return resp;
}
async getCurrentExecutionResult(): Promise<ExecutionResult> {
const { output, exitCode } = await this.waitTillOscCode('exit');
return { output, exitCode };
}
onQRCodeDetected?: (qrCode: string) => void;
async waitTillOscCode(waitCode: string) {
let fullOutput = '';
let exitCode: number = 0;
let buffer = ''; // <-- Add a buffer to accumulate output
if (!this.#outputStream) {
return { output: fullOutput, exitCode };
}
const tappedStream = this.#outputStream;
// Regex for Expo URL
const expoUrlRegex = /(exp:\/\/[^\s]+)/;
while (true) {
const { value, done } = await tappedStream.read();
if (done) {
break;
}
const text = value || '';
fullOutput += text;
buffer += text; // <-- Accumulate in buffer
// Extract Expo URL from buffer and set store
const expoUrlMatch = buffer.match(expoUrlRegex);
if (expoUrlMatch) {
// Remove any trailing ANSI escape codes or non-printable characters
const cleanUrl = expoUrlMatch[1]
.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '')
.replace(/[^\x20-\x7E]+$/g, '');
expoUrlAtom.set(cleanUrl);
// Remove everything up to and including the URL from the buffer to avoid duplicate matches
buffer = buffer.slice(buffer.indexOf(expoUrlMatch[1]) + expoUrlMatch[1].length);
}
// Check if command completion signal with exit code
const [, osc, , , code] = text.match(/\x1b\]654;([^\x07=]+)=?((-?\d+):(\d+))?\x07/) || [];
if (osc === 'exit') {
exitCode = parseInt(code, 10);
}
if (osc === waitCode) {
break;
}
}
return { output: fullOutput, exitCode };
}
}
/**
* Cleans and formats terminal output while preserving structure and paths
* Handles ANSI, OSC, and various terminal control sequences
*/
export function cleanTerminalOutput(input: string): string {
// Step 1: Remove OSC sequences (including those with parameters)
const removeOsc = input
.replace(/\x1b\](\d+;[^\x07\x1b]*|\d+[^\x07\x1b]*)\x07/g, '')
.replace(/\](\d+;[^\n]*|\d+[^\n]*)/g, '');
// Step 2: Remove ANSI escape sequences and color codes more thoroughly
const removeAnsi = removeOsc
// Remove all escape sequences with parameters
.replace(/\u001b\[[\?]?[0-9;]*[a-zA-Z]/g, '')
.replace(/\x1b\[[\?]?[0-9;]*[a-zA-Z]/g, '')
// Remove color codes
.replace(/\u001b\[[0-9;]*m/g, '')
.replace(/\x1b\[[0-9;]*m/g, '')
// Clean up any remaining escape characters
.replace(/\u001b/g, '')
.replace(/\x1b/g, '');
// Step 3: Clean up carriage returns and newlines
const cleanNewlines = removeAnsi
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/\n{3,}/g, '\n\n');
// Step 4: Add newlines at key breakpoints while preserving paths
const formatOutput = cleanNewlines
// Preserve prompt line
.replace(/^([~\/][^\n]+)/m, '$1\n')
// Add newline before command output indicators
.replace(/(?<!^|\n)>/g, '\n>')
// Add newline before error keywords without breaking paths
.replace(/(?<!^|\n|\w)(error|failed|warning|Error|Failed|Warning):/g, '\n$1:')
// Add newline before 'at' in stack traces without breaking paths
.replace(/(?<!^|\n|\/)(at\s+(?!async|sync))/g, '\nat ')
// Ensure 'at async' stays on same line
.replace(/\bat\s+async/g, 'at async')
// Add newline before npm error indicators
.replace(/(?<!^|\n)(npm ERR!)/g, '\n$1');
// Step 5: Clean up whitespace while preserving intentional spacing
const cleanSpaces = formatOutput
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('\n');
// Step 6: Final cleanup
return cleanSpaces
.replace(/\n{3,}/g, '\n\n') // Replace multiple newlines with double newlines
.replace(/:\s+/g, ': ') // Normalize spacing after colons
.replace(/\s{2,}/g, ' ') // Remove multiple spaces
.replace(/^\s+|\s+$/g, '') // Trim start and end
.replace(/\u0000/g, ''); // Remove null characters
}
export function newBoltShellProcess() {
return new BoltShell();
}