feat: implement centralized logging system

Components:
- CentralizedLogger static class for log aggregation
- Logger class with source context (background/content/popup/options)
- Persistent storage in chrome.storage.local (up to 1000 entries)
- Log viewer UI with filtering, search, and export
- Survives service worker restarts

Critical for MV3 debugging where service workers terminate frequently.
Provides unified debugging across all extension contexts.
This commit is contained in:
Octech2722 2025-10-18 12:08:41 -05:00
parent acbd5c8bcf
commit 6811b91a17
4 changed files with 1413 additions and 0 deletions

View File

@ -0,0 +1,280 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trilium Web Clipper - Log Viewer</title>
<style>
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a1a;
color: #e0e0e0;
margin: 0;
padding: 0;
height: 100vh;
overflow: hidden;
}
.container {
width: 100vw;
height: 100vh;
background: #2d2d2d;
padding: 20px;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
h1 {
color: #ffffff;
margin: 0 0 20px 0;
font-size: 24px;
flex-shrink: 0;
}
/* Simple controls */
.controls {
background: #3d3d3d;
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
flex-shrink: 0;
flex-wrap: wrap;
display: flex;
gap: 5px;
align-items: center;
}
.controls label {
margin-right: 4px;
font-size: 14px;
font-weight: 500;
color: #f0f0f0;
line-height: 1.4;
}
select, input {
padding: 6px 10px;
border: 1px solid #555;
border-radius: 4px;
background: #2d2d2d;
color: #e0e0e0;
}
select {
padding-right: 30px;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23e0e0e0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 16px;
min-width: 120px;
}
button {
padding: 6px 12px;
border: none;
border-radius: 4px;
background: #007cba;
color: white;
cursor: pointer;
}
button:hover {
background: #005a87;
}
/* Simple log container */
.logs-container {
background: #2d2d2d;
border: 1px solid #555;
border-radius: 6px;
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* Simple log entry */
.log-item {
padding: 12px;
border-bottom: 1px solid #444;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.4;
}
.log-item:last-child {
border-bottom: none;
}
.log-meta {
color: #888;
font-size: 11px;
margin-bottom: 4px;
}
.log-content {
color: #e0e0e0;
word-wrap: break-word;
}
.log-level {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: bold;
text-transform: uppercase;
margin-right: 8px;
}
.log-level.info { background: #17a2b8; }
.log-level.debug { background: #6c757d; }
.log-level.warn { background: #ffc107; color: #000; }
.log-level.error { background: #dc3545; }
.expand-btn {
background: #555;
color: #ccc;
font-size: 11px;
padding: 2px 6px;
margin-top: 4px;
cursor: pointer;
border: none;
border-radius: 3px;
}
.expand-btn:hover {
background: #666;
}
/* Auto-refresh indicators */
#auto-refresh-interval {
min-width: 100px;
}
.auto-refresh-status {
font-size: 11px;
color: #888;
}
.new-logs-indicator {
color: #28a745;
font-weight: bold;
}
.log-details {
background: #1a1a1a;
padding: 8px;
margin-top: 4px;
border-radius: 3px;
font-size: 11px;
color: #ccc;
white-space: pre-wrap;
border: 1px solid #444;
max-height: 300px;
overflow-y: auto;
}
/* Expand/Collapse all buttons container */
.expand-collapse-controls {
margin-left: auto;
display: flex;
gap: 5px;
}
#expand-all-btn:hover {
background: #218838;
}
#collapse-all-btn:hover {
background: #5a6268;
}
/* Responsive design */
@media (max-width: 768px) {
.container {
padding: 10px;
}
.controls {
flex-direction: column;
align-items: stretch;
}
.controls > * {
margin-bottom: 10px;
}
.expand-collapse-controls {
margin-left: 0;
order: -1; /* Show expand/collapse buttons at top on mobile */
}
h1 {
font-size: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Extension Log Viewer</h1>
<div class="controls">
<label>Level:</label>
<select id="level-filter">
<option value="">All Levels</option>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warning</option>
<option value="error">Error</option>
</select>
<label>Source:</label>
<select id="source-filter">
<option value="">All Sources</option>
<option value="background">Background</option>
<option value="content">Content</option>
<option value="popup">Popup</option>
<option value="options">Options</option>
</select>
<input type="search" id="search-box" placeholder="Search logs...">
<label>Auto-refresh:</label>
<select id="auto-refresh-interval">
<option value="0">Off</option>
<option value="1000">1 second</option>
<option value="2000">2 seconds</option>
<option value="5000" selected>5 seconds</option>
<option value="10000">10 seconds</option>
<option value="30000">30 seconds</option>
<option value="60000">1 minute</option>
</select>
<button id="refresh-btn">Refresh</button>
<button id="export-btn">Export</button>
<button id="clear-btn" style="background: #dc3545;">Clear All</button>
<div class="expand-collapse-controls">
<button id="expand-all-btn" style="background: #28a745;">Expand All</button>
<button id="collapse-all-btn" style="background: #6c757d;">Collapse All</button>
</div>
</div>
<div class="logs-container">
<div id="logs-list">Loading logs...</div>
</div>
</div>
<script type="module" src="logs.ts"></script>
</body>
</html>

View File

@ -0,0 +1,495 @@
/*
* Clean, simple log viewer CSS - no complex layouts
* This file is now unused - styles are inline in index.html
* Keeping this file for compatibility but styles are embedded
*/
body {
background: #1a1a1a;
color: #e0e0e0;
}
/* Force normal text layout for all log elements */
.log-entry * {
writing-mode: horizontal-tb !important;
text-orientation: mixed !important;
direction: ltr !important;
}
/* Force vertical stacking - override any inherited flexbox/grid/column layouts */
.log-entries, #logs-list {
display: block !important;
flex-direction: column !important;
grid-template-columns: none !important;
column-count: 1 !important;
columns: none !important;
}
.log-entry {
break-inside: avoid !important;
page-break-inside: avoid !important;
}
/* Nuclear option - force all log entries to stack vertically */
.log-entries .log-entry {
display: block !important;
width: 100% !important;
float: none !important;
position: relative !important;
left: 0 !important;
right: 0 !important;
top: auto !important;
margin-right: 0 !important;
margin-left: 0 !important;
}
/* Make sure no flexbox/grid on any parent containers */
.log-entries * {
box-sizing: border-box !important;
}
.container {
background: var(--color-surface);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow-lg);
max-width: 1200px;
margin: 0 auto;
border: 1px solid var(--color-border-primary);
}
h1 {
color: var(--color-text-primary);
margin-bottom: 20px;
font-size: 24px;
font-weight: 600;
}
.controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
padding: 15px;
background: var(--color-surface-secondary);
border-radius: 6px;
border: 1px solid var(--color-border-primary);
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
}
label {
font-weight: 500;
color: var(--color-text-primary);
font-size: 14px;
}
select,
input[type="text"],
input[type="search"] {
padding: 6px 10px;
border: 1px solid var(--color-border-primary);
border-radius: 4px;
font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
transition: var(--theme-transition);
}
select:focus,
input[type="text"]:focus,
input[type="search"]:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-light);
}
button {
background: var(--color-primary);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: var(--theme-transition);
}
button:hover {
background: var(--color-primary-hover);
}
button:active {
transform: translateY(1px);
}
.secondary-btn {
background: var(--color-surface);
color: var(--color-text-primary);
border: 1px solid var(--color-border-primary);
}
.secondary-btn:hover {
background: var(--color-surface-hover);
}
.danger-btn {
background: var(--color-error);
}
.danger-btn:hover {
background: var(--color-error-hover);
}
/* Log entries */
.log-entries {
max-height: 70vh;
overflow-y: auto;
border: 1px solid var(--color-border-primary);
border-radius: 6px;
background: var(--color-surface);
display: block !important;
width: 100%;
}
#logs-list {
display: block !important;
width: 100%;
column-count: unset !important;
columns: unset !important;
}
.log-entry {
display: block !important;
width: 100% !important;
max-width: 100% !important;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border-primary);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.4;
margin-bottom: 0;
background: var(--color-surface);
float: none !important;
position: static !important;
flex: none !important;
clear: both !important;
}
.log-entry:last-child {
border-bottom: none;
}
.log-entry:hover {
background: var(--color-surface-hover);
}
.log-header {
display: block;
width: 100%;
margin-bottom: 6px;
font-size: 12px;
}
.log-timestamp {
color: var(--color-text-secondary);
display: inline-block;
margin-right: 12px;
}
.log-level {
font-weight: 600;
text-transform: uppercase;
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
min-width: 50px;
text-align: center;
margin-right: 8px;
}
.log-level.debug {
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
}
.log-level.info {
background: var(--color-info-bg);
color: var(--color-info-text);
}
.log-level.warn {
background: var(--color-warning-bg);
color: var(--color-warning-text);
}
.log-level.error {
background: var(--color-error-bg);
color: var(--color-error-text);
}
.log-source {
background: var(--color-primary-light);
color: var(--color-primary);
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
font-weight: 500;
display: inline-block;
min-width: 70px;
text-align: center;
}
.log-message {
color: var(--color-text-primary);
display: block;
width: 100%;
margin-top: 4px;
word-wrap: break-word;
overflow-wrap: break-word;
clear: both;
}
.log-message-text {
display: block;
width: 100%;
margin-bottom: 4px;
}
.log-message-text.truncated {
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.expand-btn {
display: inline-block;
margin-top: 4px;
padding: 2px 8px;
background: var(--color-primary-light);
color: var(--color-primary);
border: none;
border-radius: 3px;
font-size: 11px;
cursor: pointer;
font-family: inherit;
}
.expand-btn:hover {
background: var(--color-primary);
color: white;
}
.log-data {
margin-top: 8px;
padding: 8px;
background: var(--color-surface-secondary);
border-radius: 4px;
border: 1px solid var(--color-border-primary);
font-size: 12px;
color: var(--color-text-secondary);
white-space: pre-wrap;
overflow-x: auto;
}
/* Statistics */
.stats {
display: flex;
gap: 20px;
margin-bottom: 20px;
padding: 15px;
background: var(--color-surface-secondary);
border-radius: 6px;
border: 1px solid var(--color-border-primary);
flex-wrap: wrap;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--color-primary);
}
.stat-label {
font-size: 12px;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 40px;
color: var(--color-text-secondary);
}
.empty-state h3 {
color: var(--color-text-primary);
margin-bottom: 10px;
}
/* Theme toggle */
.theme-toggle {
background: var(--color-surface);
border: 1px solid var(--color-border-primary);
color: var(--color-text-secondary);
}
.theme-toggle:hover {
background: var(--color-surface-hover);
color: var(--color-text-primary);
}
/* Responsive design */
@media (max-width: 768px) {
body {
padding: 10px;
}
.container {
padding: 15px;
}
.controls {
flex-direction: column;
align-items: stretch;
}
.control-group {
justify-content: space-between;
}
.log-entry {
display: block !important;
width: 100% !important;
}
.log-timestamp,
.log-level,
.log-source {
min-width: auto;
}
.stats {
justify-content: center;
}
}
/* Loading state */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40px;
color: var(--color-text-secondary);
}
.loading::after {
content: '';
width: 20px;
height: 20px;
border: 2px solid var(--color-border-primary);
border-top: 2px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Scrollbar styling */
.log-entries::-webkit-scrollbar {
width: 8px;
}
.log-entries::-webkit-scrollbar-track {
background: var(--color-surface-secondary);
}
.log-entries::-webkit-scrollbar-thumb {
background: var(--color-border-primary);
border-radius: 4px;
}
.log-entries::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
}
/* Export dialog styling */
.export-dialog {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.export-content {
background: var(--color-surface);
padding: 24px;
border-radius: 8px;
box-shadow: var(--shadow-lg);
max-width: 500px;
width: 90%;
border: 1px solid var(--color-border-primary);
}
.export-content h3 {
margin-top: 0;
color: var(--color-text-primary);
}
.export-options {
display: flex;
flex-direction: column;
gap: 12px;
margin: 20px 0;
}
.export-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border: 1px solid var(--color-border-primary);
border-radius: 4px;
cursor: pointer;
transition: var(--theme-transition);
}
.export-option:hover {
background: var(--color-surface-hover);
}
.export-option input[type="radio"] {
margin: 0;
}
.export-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}

View File

@ -0,0 +1,294 @@
import { CentralizedLogger, LogEntry } from '@/shared/utils';
class SimpleLogViewer {
private logs: LogEntry[] = [];
private autoRefreshTimer: number | null = null;
private lastLogCount: number = 0;
private autoRefreshEnabled: boolean = true;
private expandedLogs: Set<string> = new Set(); // Track which logs are expanded
constructor() {
this.initialize();
}
private async initialize(): Promise<void> {
this.setupEventHandlers();
await this.loadLogs();
}
private setupEventHandlers(): void {
const refreshBtn = document.getElementById('refresh-btn');
const exportBtn = document.getElementById('export-btn');
const clearBtn = document.getElementById('clear-btn');
const expandAllBtn = document.getElementById('expand-all-btn');
const collapseAllBtn = document.getElementById('collapse-all-btn');
const levelFilter = document.getElementById('level-filter') as HTMLSelectElement;
const sourceFilter = document.getElementById('source-filter') as HTMLSelectElement;
const searchBox = document.getElementById('search-box') as HTMLInputElement;
const autoRefreshSelect = document.getElementById('auto-refresh-interval') as HTMLSelectElement;
refreshBtn?.addEventListener('click', () => this.loadLogs());
exportBtn?.addEventListener('click', () => this.exportLogs());
clearBtn?.addEventListener('click', () => this.clearLogs());
expandAllBtn?.addEventListener('click', () => this.expandAllLogs());
collapseAllBtn?.addEventListener('click', () => this.collapseAllLogs());
levelFilter?.addEventListener('change', () => this.renderLogs());
sourceFilter?.addEventListener('change', () => this.renderLogs());
searchBox?.addEventListener('input', () => this.renderLogs());
autoRefreshSelect?.addEventListener('change', (e) => this.handleAutoRefreshChange(e));
// Start auto-refresh with default interval (5 seconds)
this.startAutoRefresh(5000);
// Pause auto-refresh when tab is not visible
this.setupVisibilityHandling();
}
private setupVisibilityHandling(): void {
document.addEventListener('visibilitychange', () => {
this.autoRefreshEnabled = !document.hidden;
// If tab becomes visible again, refresh immediately
if (!document.hidden) {
this.loadLogs();
}
});
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
this.stopAutoRefresh();
});
}
private async loadLogs(): Promise<void> {
try {
const newLogs = await CentralizedLogger.getLogs();
const hasNewLogs = newLogs.length !== this.lastLogCount;
this.logs = newLogs;
this.lastLogCount = newLogs.length;
this.renderLogs();
// Show notification if new logs arrived during auto-refresh
if (hasNewLogs && this.logs.length > 0) {
this.showNewLogsIndicator();
}
} catch (error) {
console.error('Failed to load logs:', error);
this.showError('Failed to load logs');
}
}
private handleAutoRefreshChange(event: Event): void {
const select = event.target as HTMLSelectElement;
const interval = parseInt(select.value);
if (interval === 0) {
this.stopAutoRefresh();
} else {
this.startAutoRefresh(interval);
}
}
private startAutoRefresh(intervalMs: number): void {
this.stopAutoRefresh(); // Clear any existing timer
if (intervalMs > 0) {
this.autoRefreshTimer = window.setInterval(() => {
if (this.autoRefreshEnabled) {
this.loadLogs();
}
}, intervalMs);
}
}
private stopAutoRefresh(): void {
if (this.autoRefreshTimer) {
clearInterval(this.autoRefreshTimer);
this.autoRefreshTimer = null;
}
}
private showNewLogsIndicator(): void {
// Flash the refresh button to indicate new logs
const refreshBtn = document.getElementById('refresh-btn');
if (refreshBtn) {
refreshBtn.style.background = '#28a745';
refreshBtn.textContent = 'New logs!';
setTimeout(() => {
refreshBtn.style.background = '#007cba';
refreshBtn.textContent = 'Refresh';
}, 2000);
}
}
private renderLogs(): void {
const logsList = document.getElementById('logs-list');
if (!logsList) return;
// Apply filters
const levelFilter = (document.getElementById('level-filter') as HTMLSelectElement).value;
const sourceFilter = (document.getElementById('source-filter') as HTMLSelectElement).value;
const searchQuery = (document.getElementById('search-box') as HTMLInputElement).value.toLowerCase();
let filteredLogs = this.logs.filter(log => {
if (levelFilter && log.level !== levelFilter) return false;
if (sourceFilter && log.source !== sourceFilter) return false;
if (searchQuery) {
const searchText = `${log.context} ${log.message}`.toLowerCase();
if (!searchText.includes(searchQuery)) return false;
}
return true;
});
// Sort by timestamp (newest first)
filteredLogs.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
if (filteredLogs.length === 0) {
logsList.innerHTML = '<div style="padding: 20px; text-align: center; color: #888;">No logs found</div>';
return;
}
// Render simple log entries
logsList.innerHTML = filteredLogs.map(log => this.renderLogItem(log)).join('');
// Add event listeners for expand buttons
this.setupExpandButtons();
}
private setupExpandButtons(): void {
const expandButtons = document.querySelectorAll('.expand-btn');
expandButtons.forEach(button => {
button.addEventListener('click', (e) => {
const btn = e.target as HTMLButtonElement;
const logId = btn.getAttribute('data-log-id');
if (!logId) return;
const details = document.getElementById(`details-${logId}`);
if (!details) return;
if (this.expandedLogs.has(logId)) {
// Collapse
details.style.display = 'none';
btn.textContent = 'Expand';
this.expandedLogs.delete(logId);
} else {
// Expand
details.style.display = 'block';
btn.textContent = 'Collapse';
this.expandedLogs.add(logId);
}
});
});
}
private renderLogItem(log: LogEntry): string {
const timestamp = new Date(log.timestamp).toLocaleString();
const message = this.escapeHtml(`[${log.context}] ${log.message}`);
// Handle additional data
let details = '';
if (log.args && log.args.length > 0) {
details += `<div class="log-details">${JSON.stringify(log.args, null, 2)}</div>`;
}
if (log.error) {
details += `<div class="log-details">Error: ${log.error.name}: ${log.error.message}</div>`;
}
const needsExpand = message.length > 200 || details;
const displayMessage = needsExpand ? message.substring(0, 200) + '...' : message;
// Check if this log is currently expanded
const isExpanded = this.expandedLogs.has(log.id);
const displayStyle = isExpanded ? 'block' : 'none';
const buttonText = isExpanded ? 'Collapse' : 'Expand';
return `
<div class="log-item">
<div class="log-meta">
${timestamp}
<span class="log-level ${log.level}">${log.level}</span>
<span style="color: #007cba;">${log.source}</span>
</div>
<div class="log-content">
${displayMessage}
${needsExpand ? `<button class="expand-btn" data-log-id="${log.id}">${buttonText}</button>` : ''}
${needsExpand ? `<div class="log-details" id="details-${log.id}" style="display: ${displayStyle};">${message}${details}</div>` : ''}
</div>
</div>
`;
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
private async exportLogs(): Promise<void> {
try {
const logsJson = await CentralizedLogger.exportLogs();
const blob = new Blob([logsJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `trilium-logs-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to export logs:', error);
}
}
private async clearLogs(): Promise<void> {
if (confirm('Are you sure you want to clear all logs?')) {
try {
await CentralizedLogger.clearLogs();
this.logs = [];
this.expandedLogs.clear(); // Clear expanded state when clearing logs
this.renderLogs();
} catch (error) {
console.error('Failed to clear logs:', error);
}
}
}
private expandAllLogs(): void {
// Get all currently visible logs that can be expanded
const expandButtons = document.querySelectorAll('.expand-btn');
expandButtons.forEach(button => {
const logId = button.getAttribute('data-log-id');
if (logId) {
this.expandedLogs.add(logId);
}
});
// Re-render to apply the expanded state
this.renderLogs();
}
private collapseAllLogs(): void {
// Clear all expanded states
this.expandedLogs.clear();
// Re-render to apply the collapsed state
this.renderLogs();
}
private showError(message: string): void {
const logsList = document.getElementById('logs-list');
if (logsList) {
logsList.innerHTML = `<div style="padding: 20px; color: #dc3545; text-align: center;">${message}</div>`;
}
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new SimpleLogViewer();
});

View File

@ -0,0 +1,344 @@
/**
* Log entry interface for centralized logging
*/
export interface LogEntry {
id: string;
timestamp: string;
level: 'debug' | 'info' | 'warn' | 'error';
context: string;
message: string;
args?: unknown[];
error?: {
name: string;
message: string;
stack?: string;
};
source: 'background' | 'content' | 'popup' | 'options';
}
/**
* Centralized logging system for the extension
* Aggregates logs from all contexts and provides unified access
*/
export class CentralizedLogger {
private static readonly MAX_LOGS = 1000;
private static readonly STORAGE_KEY = 'extension_logs';
/**
* Add a log entry to centralized storage
*/
static async addLog(entry: Omit<LogEntry, 'id' | 'timestamp'>): Promise<void> {
try {
const logEntry: LogEntry = {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
...entry,
};
// Get existing logs
const result = await chrome.storage.local.get(this.STORAGE_KEY);
const logs: LogEntry[] = result[this.STORAGE_KEY] || [];
// Add new log and maintain size limit
logs.push(logEntry);
if (logs.length > this.MAX_LOGS) {
logs.splice(0, logs.length - this.MAX_LOGS);
}
// Store updated logs
await chrome.storage.local.set({ [this.STORAGE_KEY]: logs });
} catch (error) {
console.error('Failed to store centralized log:', error);
}
}
/**
* Get all logs from centralized storage
*/
static async getLogs(): Promise<LogEntry[]> {
try {
const result = await chrome.storage.local.get(this.STORAGE_KEY);
return result[this.STORAGE_KEY] || [];
} catch (error) {
console.error('Failed to retrieve logs:', error);
return [];
}
}
/**
* Clear all logs
*/
static async clearLogs(): Promise<void> {
try {
await chrome.storage.local.remove(this.STORAGE_KEY);
} catch (error) {
console.error('Failed to clear logs:', error);
}
}
/**
* Export logs as JSON string
*/
static async exportLogs(): Promise<string> {
const logs = await this.getLogs();
return JSON.stringify(logs, null, 2);
}
/**
* Get logs filtered by level
*/
static async getLogsByLevel(level: LogEntry['level']): Promise<LogEntry[]> {
const logs = await this.getLogs();
return logs.filter(log => log.level === level);
}
/**
* Get logs filtered by context
*/
static async getLogsByContext(context: string): Promise<LogEntry[]> {
const logs = await this.getLogs();
return logs.filter(log => log.context === context);
}
/**
* Get logs filtered by source
*/
static async getLogsBySource(source: LogEntry['source']): Promise<LogEntry[]> {
const logs = await this.getLogs();
return logs.filter(log => log.source === source);
}
}
/**
* Enhanced logging system for the extension with centralized storage
*/
export class Logger {
private context: string;
private source: LogEntry['source'];
private isDebugMode: boolean = process.env.NODE_ENV === 'development';
constructor(context: string, source: LogEntry['source'] = 'background') {
this.context = context;
this.source = source;
}
static create(context: string, source: LogEntry['source'] = 'background'): Logger {
return new Logger(context, source);
}
private async logToStorage(level: LogEntry['level'], message: string, args?: unknown[], error?: Error): Promise<void> {
const logEntry: Omit<LogEntry, 'id' | 'timestamp'> = {
level,
context: this.context,
message,
source: this.source,
args: args && args.length > 0 ? args : undefined,
error: error ? {
name: error.name,
message: error.message,
stack: error.stack,
} : undefined,
};
await CentralizedLogger.addLog(logEntry);
}
private formatMessage(level: string, message: string, ...args: unknown[]): void {
const timestamp = new Date().toISOString();
const prefix = `[${timestamp}] [${this.source}:${this.context}] [${level.toUpperCase()}]`;
if (this.isDebugMode || level === 'ERROR') {
const consoleMethod = console[level as keyof typeof console] as (...args: unknown[]) => void;
if (typeof consoleMethod === 'function') {
consoleMethod(prefix, message, ...args);
}
}
}
debug(message: string, ...args: unknown[]): void {
this.formatMessage('debug', message, ...args);
this.logToStorage('debug', message, args).catch(console.error);
}
info(message: string, ...args: unknown[]): void {
this.formatMessage('info', message, ...args);
this.logToStorage('info', message, args).catch(console.error);
}
warn(message: string, ...args: unknown[]): void {
this.formatMessage('warn', message, ...args);
this.logToStorage('warn', message, args).catch(console.error);
}
error(message: string, error?: Error, ...args: unknown[]): void {
this.formatMessage('error', message, error, ...args);
this.logToStorage('error', message, args, error).catch(console.error);
// In production, you might want to send errors to a logging service
if (!this.isDebugMode && error) {
this.reportError(error, message);
}
}
private async reportError(error: Error, context: string): Promise<void> {
try {
// Store error details for debugging
await chrome.storage.local.set({
[`error_${Date.now()}`]: {
message: error.message,
stack: error.stack,
context,
timestamp: new Date().toISOString()
}
});
} catch (e) {
console.error('Failed to store error:', e);
}
}
}
/**
* Utility functions
*/
export const Utils = {
/**
* Generate a random string of specified length
*/
randomString(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
},
/**
* Get the base URL of the current page
*/
getBaseUrl(url: string = window.location.href): string {
try {
const urlObj = new URL(url);
return `${urlObj.protocol}//${urlObj.host}`;
} catch (error) {
return '';
}
},
/**
* Convert a relative URL to absolute
*/
makeAbsoluteUrl(relativeUrl: string, baseUrl: string): string {
try {
return new URL(relativeUrl, baseUrl).href;
} catch (error) {
return relativeUrl;
}
},
/**
* Sanitize HTML content
*/
sanitizeHtml(html: string): string {
const div = document.createElement('div');
div.textContent = html;
return div.innerHTML;
},
/**
* Debounce function calls
*/
debounce<T extends (...args: unknown[]) => void>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
},
/**
* Sleep for specified milliseconds
*/
sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
},
/**
* Retry a function with exponential backoff
*/
async retry<T>(
fn: () => Promise<T>,
maxAttempts: number = 3,
baseDelay: number = 1000
): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (attempt === maxAttempts) {
throw lastError;
}
const delay = baseDelay * Math.pow(2, attempt - 1);
await this.sleep(delay);
}
}
throw lastError!;
}
};
/**
* Message handling utilities
*/
export const MessageUtils = {
/**
* Send a message with automatic retry and error handling
*/
async sendMessage<T>(message: unknown, tabId?: number): Promise<T> {
const logger = Logger.create('MessageUtils');
try {
const response = tabId
? await chrome.tabs.sendMessage(tabId, message)
: await chrome.runtime.sendMessage(message);
return response as T;
} catch (error) {
logger.error('Failed to send message', error as Error, { message, tabId });
throw error;
}
},
/**
* Create a message response handler
*/
createResponseHandler<T>(
handler: (message: unknown, sender: chrome.runtime.MessageSender) => Promise<T> | T,
source: LogEntry['source'] = 'background'
) {
return (
message: unknown,
sender: chrome.runtime.MessageSender,
sendResponse: (response: T) => void
): boolean => {
const logger = Logger.create('MessageHandler', source);
Promise.resolve(handler(message, sender))
.then(sendResponse)
.catch(error => {
logger.error('Message handler failed', error as Error, { message, sender });
sendResponse({ error: error.message } as T);
});
return true; // Indicates async response
};
}
};