Skip to content

Commit 14bb032

Browse files
committed
fix(diagnostics): capture console output in diagnostics exports
1 parent c493818 commit 14bb032

2 files changed

Lines changed: 126 additions & 1 deletion

File tree

src/app/utils/debugLogger.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,61 @@ describe('debug logger diagnostic capture', () => {
7979
expect(logger.getLogs().some((entry) => entry.message === 'before capture')).toBe(false);
8080
});
8181

82+
it('captures console output while a capture session is active and restores console after', () => {
83+
const originalError = console.error;
84+
const since = logger.startCapture();
85+
86+
console.error('sdk sync failed', { url: 'https://matrix.example/sync' });
87+
console.warn('sdk retrying');
88+
logger.stopCapture();
89+
90+
expect(console.error).toBe(originalError);
91+
expect(logger.getFilteredLogs({ since })).toEqual(
92+
expect.arrayContaining([
93+
expect.objectContaining({
94+
level: 'error',
95+
namespace: 'console',
96+
message: expect.stringContaining('sdk sync failed'),
97+
}),
98+
expect.objectContaining({
99+
level: 'warn',
100+
namespace: 'console',
101+
message: 'sdk retrying',
102+
}),
103+
])
104+
);
105+
});
106+
107+
it('scrubs sensitive data from console-captured entries', () => {
108+
logger.startCapture();
109+
110+
console.error(
111+
'sync failed https://matrix.example/_matrix/client/v3/sync?access_token=syt_secret for @alice:example.org in !room:example.org'
112+
);
113+
logger.stopCapture();
114+
115+
const stored = JSON.stringify(logger.getLogs());
116+
expect(stored).not.toContain('matrix.example');
117+
expect(stored).not.toContain('syt_secret');
118+
expect(stored).not.toContain('alice');
119+
expect(stored).not.toContain('!room:example.org');
120+
expect(stored).toContain('[REDACTED_URL]');
121+
});
122+
123+
it('does not recurse when debug logging is enabled during capture', () => {
124+
logger.setEnabled(true);
125+
logger.startCapture();
126+
127+
expect(() => console.error('from app')).not.toThrow();
128+
129+
logger.stopCapture();
130+
logger.setEnabled(false);
131+
const consoleEntries = logger
132+
.getLogs()
133+
.filter((entry) => entry.namespace === 'console' && entry.message.includes('from app'));
134+
expect(consoleEntries).toHaveLength(1);
135+
});
136+
82137
it('sanitizes entries before storing them in memory', () => {
83138
logger.log('error', 'network', 'test', 'request https://matrix.example/@alice:example.org', {
84139
access_token: 'secret',

src/app/utils/debugLogger.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,29 @@ type LogListener = (entry: LogEntry) => void;
3535

3636
const BREADCRUMB_DISABLED_KEY = 'sable_sentry_breadcrumb_disabled';
3737

38+
type ConsoleMethod = 'error' | 'warn' | 'info' | 'log' | 'debug';
39+
40+
const CONSOLE_METHODS: ConsoleMethod[] = ['error', 'warn', 'info', 'log', 'debug'];
41+
42+
const MAX_CONSOLE_MESSAGE_LENGTH = 1000;
43+
44+
const formatConsoleArgs = (args: unknown[]): string => {
45+
const text = args
46+
.map((arg) => {
47+
if (typeof arg === 'string') return arg;
48+
if (arg instanceof Error) return arg.stack ?? arg.message;
49+
try {
50+
return JSON.stringify(arg);
51+
} catch {
52+
return String(arg);
53+
}
54+
})
55+
.join(' ');
56+
return text.length > MAX_CONSOLE_MESSAGE_LENGTH
57+
? `${text.slice(0, MAX_CONSOLE_MESSAGE_LENGTH)}…`
58+
: text;
59+
};
60+
3861
class DebugLoggerService {
3962
private logs: LogEntry[] = [];
4063

@@ -52,6 +75,12 @@ class DebugLoggerService {
5275

5376
private sentryStats = { errors: 0, warnings: 0 };
5477

78+
private originalConsole: Partial<Record<ConsoleMethod, (...args: unknown[]) => void>> = {};
79+
80+
private consoleIntercepted = false;
81+
82+
private writingToConsole = false;
83+
5584
constructor() {
5685
// Check if debug logging is enabled from localStorage
5786
this.enabled = localStorage.getItem('sable_internal_debug') === '1';
@@ -91,6 +120,7 @@ class DebugLoggerService {
91120
this.clear();
92121
this.captureActive = true;
93122
this.captureSince = Date.now();
123+
this.interceptConsole();
94124
this.log('info', 'general', 'diagnostics', 'Diagnostic capture started');
95125
return this.captureSince;
96126
}
@@ -99,9 +129,44 @@ class DebugLoggerService {
99129
if (!this.captureActive) return this.captureSince;
100130
this.log('info', 'general', 'diagnostics', 'Diagnostic capture stopped');
101131
this.captureActive = false;
132+
this.restoreConsole();
102133
return this.captureSince;
103134
}
104135

136+
/**
137+
* Funnels console output (including matrix-js-sdk's logger, which writes to the
138+
* console) into the capture buffer for the duration of a diagnostics session.
139+
*/
140+
private interceptConsole(): void {
141+
if (this.consoleIntercepted) return;
142+
this.consoleIntercepted = true;
143+
CONSOLE_METHODS.forEach((method) => {
144+
const original = console[method] as (...args: unknown[]) => void;
145+
this.originalConsole[method] = original;
146+
console[method] = (...args: unknown[]) => {
147+
original.apply(console, args);
148+
if (this.writingToConsole) return;
149+
const level: LogLevel = method === 'log' ? 'debug' : method;
150+
this.log(
151+
level,
152+
level === 'error' ? 'error' : 'general',
153+
'console',
154+
formatConsoleArgs(args)
155+
);
156+
};
157+
});
158+
}
159+
160+
private restoreConsole(): void {
161+
if (!this.consoleIntercepted) return;
162+
this.consoleIntercepted = false;
163+
CONSOLE_METHODS.forEach((method) => {
164+
const original = this.originalConsole[method];
165+
if (original) console[method] = original;
166+
});
167+
this.originalConsole = {};
168+
}
169+
105170
public addListener(listener: LogListener): () => void {
106171
this.listeners.add(listener);
107172
return () => this.listeners.delete(listener);
@@ -175,7 +240,12 @@ class DebugLoggerService {
175240
// Also log to console for developer convenience
176241
const prefix = `[sable:${category}:${namespace}]`;
177242
const consoleLevel = level === 'debug' ? 'log' : level;
178-
console[consoleLevel](prefix, message, data !== undefined ? data : '');
243+
this.writingToConsole = true;
244+
try {
245+
console[consoleLevel](prefix, message, data !== undefined ? data : '');
246+
} finally {
247+
this.writingToConsole = false;
248+
}
179249
}
180250

181251
public getBreadcrumbCategoryEnabled(category: LogCategory): boolean {

0 commit comments

Comments
 (0)