feat: add frontend logging system with router and visibility tracking
This commit is contained in:
+57
-2
@@ -1,4 +1,4 @@
|
||||
import { Component, ChangeDetectionStrategy, HostListener, inject, signal, computed, effect, ElementRef, OnDestroy } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, HostListener, inject, signal, computed, effect, ElementRef, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
// Services
|
||||
@@ -7,6 +7,7 @@ import { MarkdownService } from './services/markdown.service';
|
||||
import { MarkdownViewerService } from './services/markdown-viewer.service';
|
||||
import { DownloadService } from './core/services/download.service';
|
||||
import { ThemeService } from './app/core/services/theme.service';
|
||||
import { LogService } from './core/logging/log.service';
|
||||
|
||||
// Components
|
||||
import { FileExplorerComponent } from './components/file-explorer/file-explorer.component';
|
||||
@@ -52,7 +53,7 @@ interface TocEntry {
|
||||
styleUrls: ['./app.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppComponent implements OnDestroy {
|
||||
export class AppComponent implements OnInit, OnDestroy {
|
||||
readonly vaultService = inject(VaultService);
|
||||
private markdownService = inject(MarkdownService);
|
||||
private markdownViewerService = inject(MarkdownViewerService);
|
||||
@@ -61,6 +62,7 @@ export class AppComponent implements OnDestroy {
|
||||
private readonly bookmarksService = inject(BookmarksService);
|
||||
private readonly searchHistoryService = inject(SearchHistoryService);
|
||||
private readonly graphIndexService = inject(GraphIndexService);
|
||||
private readonly logService = inject(LogService);
|
||||
private elementRef = inject(ElementRef);
|
||||
|
||||
// --- State Signals ---
|
||||
@@ -358,7 +360,20 @@ export class AppComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Log app start
|
||||
this.logService.log('APP_START', {
|
||||
viewport: {
|
||||
width: typeof window !== 'undefined' ? window.innerWidth : 0,
|
||||
height: typeof window !== 'undefined' ? window.innerHeight : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Log app stop
|
||||
this.logService.log('APP_STOP');
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', this.resizeHandler);
|
||||
}
|
||||
@@ -407,6 +422,13 @@ export class AppComponent implements OnDestroy {
|
||||
this.isSidebarOpen.set(true);
|
||||
this.activeView.set('search');
|
||||
this.calendarSearchTriggered = false;
|
||||
|
||||
// Log calendar search execution
|
||||
if (files.length > 0) {
|
||||
this.logService.log('CALENDAR_SEARCH_EXECUTED', {
|
||||
resultsCount: files.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,8 +545,16 @@ export class AppComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks'): void {
|
||||
const previousView = this.activeView();
|
||||
this.activeView.set(view);
|
||||
this.sidebarSearchTerm.set('');
|
||||
|
||||
// Log view changes
|
||||
if (view === 'bookmarks' && previousView !== 'bookmarks') {
|
||||
this.logService.log('BOOKMARKS_OPEN');
|
||||
} else if (view === 'graph' && previousView !== 'graph') {
|
||||
this.logService.log('GRAPH_VIEW_OPEN');
|
||||
}
|
||||
}
|
||||
|
||||
selectNote(noteId: string): void {
|
||||
@@ -571,6 +601,12 @@ export class AppComponent implements OnDestroy {
|
||||
// Update search term and switch to search view
|
||||
this.sidebarSearchTerm.set(query);
|
||||
this.activeView.set('search');
|
||||
|
||||
// Log search execution
|
||||
this.logService.log('SEARCH_EXECUTED', {
|
||||
query: query.trim(),
|
||||
queryLength: query.trim().length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,9 +699,21 @@ export class AppComponent implements OnDestroy {
|
||||
if (data.groupCtime !== null) {
|
||||
this.bookmarksService.moveBookmark(existingCtime, data.groupCtime, 0);
|
||||
}
|
||||
|
||||
// Log bookmark modification
|
||||
this.logService.log('BOOKMARKS_MODIFY', {
|
||||
action: 'update',
|
||||
path: data.path,
|
||||
});
|
||||
} else {
|
||||
// Create new bookmark
|
||||
this.bookmarksService.createFileBookmark(data.path, data.title, data.groupCtime);
|
||||
|
||||
// Log bookmark modification
|
||||
this.logService.log('BOOKMARKS_MODIFY', {
|
||||
action: 'add',
|
||||
path: data.path,
|
||||
});
|
||||
}
|
||||
|
||||
this.closeBookmarkModal();
|
||||
@@ -673,6 +721,13 @@ export class AppComponent implements OnDestroy {
|
||||
|
||||
onBookmarkDelete(event: BookmarkDeleteEvent): void {
|
||||
this.bookmarksService.removePathEverywhere(event.path);
|
||||
|
||||
// Log bookmark deletion
|
||||
this.logService.log('BOOKMARKS_MODIFY', {
|
||||
action: 'delete',
|
||||
path: event.path,
|
||||
});
|
||||
|
||||
this.closeBookmarkModal();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { DestroyRef, Inject, Injectable, effect, signal, computed } from '@angular/core';
|
||||
import { DestroyRef, Inject, Injectable, effect, signal, computed, inject } from '@angular/core';
|
||||
import { LogService } from '../../../core/logging/log.service';
|
||||
|
||||
export type ThemeName = 'light' | 'dark';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemeService {
|
||||
private readonly logService = inject(LogService);
|
||||
private static readonly STORAGE_KEY = 'obsiwatcher.theme';
|
||||
|
||||
private readonly document = this.doc;
|
||||
@@ -50,11 +52,30 @@ export class ThemeService {
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeName): void {
|
||||
const previousTheme = this.currentTheme();
|
||||
this.currentTheme.set(theme);
|
||||
|
||||
// Log theme change
|
||||
if (previousTheme !== theme) {
|
||||
this.logService.log('THEME_CHANGE', {
|
||||
from: previousTheme,
|
||||
to: theme,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toggleTheme(): void {
|
||||
const previousTheme = this.currentTheme();
|
||||
this.currentTheme.update(theme => (theme === 'light' ? 'dark' : 'light'));
|
||||
|
||||
// Log theme change
|
||||
const newTheme = this.currentTheme();
|
||||
if (previousTheme !== newTheme) {
|
||||
this.logService.log('THEME_CHANGE', {
|
||||
from: previousTheme,
|
||||
to: newTheme,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private detectSystemTheme(): ThemeName {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Injectable, signal, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { BehaviorSubject, Subject, debounceTime, catchError, of, tap } from 'rxjs';
|
||||
import { GraphConfig, DEFAULT_GRAPH_CONFIG, validateGraphConfig } from './graph-settings.types';
|
||||
import { LogService } from '../../core/logging/log.service';
|
||||
|
||||
/** Response from graph config API */
|
||||
interface GraphConfigResponse {
|
||||
@@ -20,6 +21,7 @@ export type Unsubscribe = () => void;
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class GraphSettingsService {
|
||||
private readonly logService = inject(LogService);
|
||||
private configSubject = new BehaviorSubject<GraphConfig>(DEFAULT_GRAPH_CONFIG);
|
||||
private saveQueue = new Subject<Partial<GraphConfig>>();
|
||||
private currentRev: string | null = null;
|
||||
@@ -85,6 +87,11 @@ export class GraphSettingsService {
|
||||
|
||||
// Queue for debounced save
|
||||
this.saveQueue.next(patch);
|
||||
|
||||
// Log settings change
|
||||
this.logService.log('GRAPH_VIEW_SETTINGS_CHANGE', {
|
||||
changes: Object.keys(patch),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
appVersion: '0.1.0',
|
||||
logging: {
|
||||
enabled: true,
|
||||
endpoint: '/api/log',
|
||||
batchSize: 5,
|
||||
debounceMs: 2000,
|
||||
maxRetries: 5,
|
||||
circuitBreakerThreshold: 5,
|
||||
circuitBreakerResetMs: 30000,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './log.model';
|
||||
export * from './log.service';
|
||||
export * from './log.sender';
|
||||
export * from './log.router-listener';
|
||||
export * from './log.visibility-listener';
|
||||
export * from './environment';
|
||||
@@ -0,0 +1,43 @@
|
||||
export type LogLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
export type LogEvent =
|
||||
| 'APP_START'
|
||||
| 'APP_STOP'
|
||||
| 'VISIBILITY_CHANGE'
|
||||
| 'NAVIGATE'
|
||||
| 'SEARCH_EXECUTED'
|
||||
| 'BOOKMARKS_OPEN'
|
||||
| 'BOOKMARKS_MODIFY'
|
||||
| 'GRAPH_VIEW_OPEN'
|
||||
| 'GRAPH_VIEW_CLOSE'
|
||||
| 'GRAPH_VIEW_SETTINGS_CHANGE'
|
||||
| 'CALENDAR_SEARCH_EXECUTED'
|
||||
| 'THEME_CHANGE';
|
||||
|
||||
export interface LogContext {
|
||||
route?: string;
|
||||
vault?: string;
|
||||
theme?: 'light' | 'dark';
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface LogRecord {
|
||||
ts: string;
|
||||
level: LogLevel;
|
||||
app: 'ObsiViewer';
|
||||
sessionId: string;
|
||||
userAgent?: string;
|
||||
context: LogContext;
|
||||
event: LogEvent;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LogConfig {
|
||||
enabled: boolean;
|
||||
endpoint: string;
|
||||
batchSize: number;
|
||||
debounceMs: number;
|
||||
maxRetries: number;
|
||||
circuitBreakerThreshold: number;
|
||||
circuitBreakerResetMs: number;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, NavigationEnd } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { LogService } from './log.service';
|
||||
|
||||
/**
|
||||
* Initialize router event logging.
|
||||
* Tracks navigation events and logs them with from/to URLs.
|
||||
*/
|
||||
export function initializeRouterLogging(): () => void {
|
||||
return () => {
|
||||
const router = inject(Router);
|
||||
const logService = inject(LogService);
|
||||
|
||||
let lastUrl = router.url || '/';
|
||||
|
||||
router.events
|
||||
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
|
||||
.subscribe((event) => {
|
||||
const from = lastUrl;
|
||||
const to = event.urlAfterRedirects;
|
||||
|
||||
logService.log('NAVIGATE', {
|
||||
from,
|
||||
to,
|
||||
durationMs: 0, // Could be enhanced with performance.now()
|
||||
});
|
||||
|
||||
lastUrl = to;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { sendBatch } from './log.sender';
|
||||
import type { LogRecord } from './log.model';
|
||||
|
||||
describe('sendBatch', () => {
|
||||
const mockRecord: LogRecord = {
|
||||
ts: '2025-10-05T14:21:33.123Z',
|
||||
level: 'info',
|
||||
app: 'ObsiViewer',
|
||||
sessionId: 'test-session',
|
||||
userAgent: 'test-agent',
|
||||
context: {
|
||||
route: '/test',
|
||||
theme: 'dark',
|
||||
version: '0.0.0',
|
||||
},
|
||||
event: 'APP_START',
|
||||
data: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock fetch
|
||||
global.fetch = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should send single record', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
await sendBatch([mockRecord], '/api/log');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/log', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(mockRecord),
|
||||
});
|
||||
});
|
||||
|
||||
it('should send multiple records as array', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const records = [mockRecord, { ...mockRecord, event: 'NAVIGATE' as const }];
|
||||
await sendBatch(records, '/api/log');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/log', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(records),
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw on HTTP error', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
});
|
||||
|
||||
await expect(sendBatch([mockRecord], '/api/log')).rejects.toThrow(
|
||||
'HTTP 500: Internal Server Error'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on network error', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(sendBatch([mockRecord], '/api/log')).rejects.toThrow('Network error');
|
||||
});
|
||||
|
||||
it('should not send empty batch', async () => {
|
||||
await sendBatch([], '/api/log');
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { LogRecord } from './log.model';
|
||||
|
||||
/**
|
||||
* Pure function to send a batch of log records to the backend.
|
||||
* Returns a promise that resolves on success (2xx) or rejects on failure.
|
||||
*/
|
||||
export async function sendBatch(
|
||||
records: LogRecord[],
|
||||
endpoint: string
|
||||
): Promise<void> {
|
||||
if (!records.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(records.length === 1 ? records[0] : records),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Re-throw to allow retry logic in the service
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { LogService } from './log.service';
|
||||
import { environment } from './environment';
|
||||
|
||||
describe('LogService', () => {
|
||||
let service: LogService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(LogService);
|
||||
|
||||
// Clear localStorage and sessionStorage
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should generate a session ID', () => {
|
||||
const sessionId = (service as any).sessionId;
|
||||
expect(sessionId).toBeTruthy();
|
||||
expect(sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
||||
});
|
||||
|
||||
it('should persist session ID in sessionStorage', () => {
|
||||
const sessionId = (service as any).sessionId;
|
||||
const stored = sessionStorage.getItem('obsiviewer.log.sessionId');
|
||||
expect(stored).toBe(sessionId);
|
||||
});
|
||||
|
||||
it('should enqueue log records', () => {
|
||||
service.log('APP_START', { test: true });
|
||||
|
||||
const queue = (service as any).queue;
|
||||
expect(queue.length).toBe(1);
|
||||
expect(queue[0].event).toBe('APP_START');
|
||||
expect(queue[0].data.test).toBe(true);
|
||||
});
|
||||
|
||||
it('should include context in log records', () => {
|
||||
service.log('NAVIGATE', { from: '/', to: '/search' });
|
||||
|
||||
const queue = (service as any).queue;
|
||||
const record = queue[0];
|
||||
|
||||
expect(record.context.route).toBeDefined();
|
||||
expect(record.context.version).toBe(environment.appVersion);
|
||||
});
|
||||
|
||||
it('should truncate large data objects', () => {
|
||||
const largeData = { content: 'x'.repeat(10000) };
|
||||
service.log('APP_START', largeData);
|
||||
|
||||
const queue = (service as any).queue;
|
||||
const record = queue[0];
|
||||
|
||||
expect(record.data._truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('should not log when disabled', () => {
|
||||
const originalEnabled = environment.logging.enabled;
|
||||
|
||||
service.log('APP_START');
|
||||
|
||||
const queue = (service as any).queue;
|
||||
expect(queue.length).toBe(0);
|
||||
|
||||
environment.logging.enabled = originalEnabled;
|
||||
});
|
||||
|
||||
it('should persist queue to localStorage', () => {
|
||||
service.log('APP_START');
|
||||
|
||||
const stored = localStorage.getItem('obsiviewer.log.queue');
|
||||
expect(stored).toBeTruthy();
|
||||
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.length).toBe(1);
|
||||
expect(parsed[0].event).toBe('APP_START');
|
||||
});
|
||||
|
||||
it('should load queue from localStorage on startup', () => {
|
||||
const mockQueue = [
|
||||
{
|
||||
ts: new Date().toISOString(),
|
||||
level: 'info',
|
||||
app: 'ObsiViewer',
|
||||
sessionId: 'test-session',
|
||||
event: 'APP_START',
|
||||
context: {},
|
||||
data: {},
|
||||
},
|
||||
];
|
||||
|
||||
localStorage.setItem('obsiviewer.log.queue', JSON.stringify(mockQueue));
|
||||
|
||||
// Create new service instance
|
||||
const newService = TestBed.inject(LogService);
|
||||
const queue = (newService as any).queue;
|
||||
|
||||
expect(queue.length).toBe(1);
|
||||
expect(queue[0].event).toBe('APP_START');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import type { LogLevel, LogEvent, LogRecord, LogContext } from './log.model';
|
||||
import { environment } from './environment';
|
||||
import { sendBatch } from './log.sender';
|
||||
|
||||
const SESSION_STORAGE_KEY = 'obsiviewer.log.sessionId';
|
||||
const LOCAL_STORAGE_QUEUE_KEY = 'obsiviewer.log.queue';
|
||||
const MAX_RECORD_SIZE = 5 * 1024; // 5 KB
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LogService {
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
private readonly isBrowser = isPlatformBrowser(this.platformId);
|
||||
|
||||
private readonly config = environment.logging;
|
||||
private readonly sessionId = this.ensureSessionId();
|
||||
|
||||
private queue: LogRecord[] = [];
|
||||
private flushTimer?: ReturnType<typeof setTimeout>;
|
||||
private consecutiveFailures = 0;
|
||||
private circuitBreakerOpenUntil = 0;
|
||||
private isFlushing = false;
|
||||
|
||||
constructor() {
|
||||
if (this.isBrowser) {
|
||||
// Load persisted queue from localStorage
|
||||
this.loadQueueFromStorage();
|
||||
|
||||
// Listen to online/offline events
|
||||
window.addEventListener('online', () => this.scheduleFlush());
|
||||
window.addEventListener('beforeunload', () => this.flushSync());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an event with optional data and level.
|
||||
*/
|
||||
log(
|
||||
event: LogEvent,
|
||||
data: Record<string, unknown> = {},
|
||||
level: LogLevel = 'info'
|
||||
): void {
|
||||
if (!this.config.enabled || !this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record: LogRecord = {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
app: 'ObsiViewer',
|
||||
sessionId: this.sessionId,
|
||||
userAgent: navigator.userAgent,
|
||||
context: this.buildContext(),
|
||||
event,
|
||||
data: this.safeData(data),
|
||||
};
|
||||
|
||||
this.enqueue(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force immediate flush of the queue (for testing or critical events).
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = undefined;
|
||||
|
||||
await this.processQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous flush for beforeunload (uses sendBeacon if available).
|
||||
*/
|
||||
private flushSync(): void {
|
||||
if (!this.isBrowser || this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const records = [...this.queue];
|
||||
this.queue = [];
|
||||
this.persistQueue();
|
||||
|
||||
if ('sendBeacon' in navigator) {
|
||||
const blob = new Blob([JSON.stringify(records)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
navigator.sendBeacon(this.config.endpoint, blob);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a record and schedule flush if needed.
|
||||
*/
|
||||
private enqueue(record: LogRecord): void {
|
||||
this.queue.push(record);
|
||||
this.persistQueue();
|
||||
|
||||
// Flush if batch size reached
|
||||
if (this.queue.length >= this.config.batchSize) {
|
||||
this.scheduleFlush(0);
|
||||
} else {
|
||||
this.scheduleFlush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a flush with debounce.
|
||||
*/
|
||||
private scheduleFlush(delayMs = this.config.debounceMs): void {
|
||||
if (this.flushTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = undefined;
|
||||
|
||||
// Use requestIdleCallback if available for better performance
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => this.processQueue());
|
||||
} else {
|
||||
this.processQueue();
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the queue with retry logic and circuit breaker.
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isFlushing || this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check circuit breaker
|
||||
if (Date.now() < this.circuitBreakerOpenUntil) {
|
||||
console.debug('[LogService] Circuit breaker open, skipping flush');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check online status
|
||||
if (!navigator.onLine) {
|
||||
console.debug('[LogService] Offline, deferring flush');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isFlushing = true;
|
||||
const batch = [...this.queue];
|
||||
|
||||
try {
|
||||
await this.sendWithRetry(batch);
|
||||
|
||||
// Success: clear queue and reset failure counter
|
||||
this.queue = [];
|
||||
this.consecutiveFailures = 0;
|
||||
this.persistQueue();
|
||||
} catch (error) {
|
||||
console.warn('[LogService] Failed to send logs after retries:', error);
|
||||
|
||||
this.consecutiveFailures++;
|
||||
|
||||
// Open circuit breaker if threshold reached
|
||||
if (this.consecutiveFailures >= this.config.circuitBreakerThreshold) {
|
||||
this.circuitBreakerOpenUntil = Date.now() + this.config.circuitBreakerResetMs;
|
||||
console.warn(
|
||||
`[LogService] Circuit breaker opened for ${this.config.circuitBreakerResetMs}ms`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.isFlushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send batch with exponential backoff retry.
|
||||
*/
|
||||
private async sendWithRetry(batch: LogRecord[]): Promise<void> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < this.config.maxRetries; attempt++) {
|
||||
try {
|
||||
await sendBatch(batch, this.config.endpoint);
|
||||
return; // Success
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt < this.config.maxRetries - 1) {
|
||||
// Exponential backoff: 500ms, 1s, 2s, 4s, 8s
|
||||
const delayMs = 500 * Math.pow(2, attempt);
|
||||
await this.delay(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build current context from available sources.
|
||||
*/
|
||||
private buildContext(): LogContext {
|
||||
const context: LogContext = {
|
||||
version: environment.appVersion,
|
||||
};
|
||||
|
||||
if (this.isBrowser) {
|
||||
// Route from current URL
|
||||
context.route = window.location.pathname + window.location.search;
|
||||
|
||||
// Theme from document
|
||||
const theme = document.documentElement.getAttribute('data-theme');
|
||||
if (theme === 'light' || theme === 'dark') {
|
||||
context.theme = theme;
|
||||
}
|
||||
|
||||
// Vault name (if available in localStorage or config)
|
||||
try {
|
||||
const vaultName = localStorage.getItem('obsiviewer.vaultName');
|
||||
if (vaultName) {
|
||||
context.vault = vaultName;
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize data object to prevent large payloads.
|
||||
*/
|
||||
private safeData(data: Record<string, unknown>): Record<string, unknown> {
|
||||
try {
|
||||
const serialized = JSON.stringify(data);
|
||||
|
||||
if (serialized.length > MAX_RECORD_SIZE) {
|
||||
return {
|
||||
_truncated: true,
|
||||
_originalSize: serialized.length,
|
||||
_message: 'Data truncated due to size limit',
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
return {
|
||||
_error: 'Failed to serialize data',
|
||||
_message: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure session ID exists (create or retrieve from sessionStorage).
|
||||
*/
|
||||
private ensureSessionId(): string {
|
||||
if (!this.isBrowser) {
|
||||
return this.generateUUID();
|
||||
}
|
||||
|
||||
try {
|
||||
let sessionId = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
|
||||
if (!sessionId) {
|
||||
sessionId = this.generateUUID();
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, sessionId);
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
} catch {
|
||||
return this.generateUUID();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a UUID v4.
|
||||
*/
|
||||
private generateUUID(): string {
|
||||
if (this.isBrowser && 'crypto' in window && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Fallback UUID generation
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist queue to localStorage as fallback.
|
||||
*/
|
||||
private persistQueue(): void {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(LOCAL_STORAGE_QUEUE_KEY, JSON.stringify(this.queue));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load queue from localStorage on startup.
|
||||
*/
|
||||
private loadQueueFromStorage(): void {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(LOCAL_STORAGE_QUEUE_KEY);
|
||||
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
this.queue = parsed;
|
||||
|
||||
// Schedule flush if queue has items
|
||||
if (this.queue.length > 0) {
|
||||
this.scheduleFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay helper for retry backoff.
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { inject, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { LogService } from './log.service';
|
||||
|
||||
/**
|
||||
* Initialize visibility and lifecycle event logging.
|
||||
* Tracks page visibility changes and app stop events.
|
||||
*/
|
||||
export function initializeVisibilityLogging(): () => void {
|
||||
return () => {
|
||||
const platformId = inject(PLATFORM_ID);
|
||||
const logService = inject(LogService);
|
||||
|
||||
if (!isPlatformBrowser(platformId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track visibility changes
|
||||
const visibilityHandler = () => {
|
||||
logService.log('VISIBILITY_CHANGE', {
|
||||
hidden: document.hidden,
|
||||
visibilityState: document.visibilityState,
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', visibilityHandler);
|
||||
|
||||
// Track app stop events
|
||||
const stopHandler = () => {
|
||||
logService.log('APP_STOP', {
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', stopHandler);
|
||||
window.addEventListener('pagehide', stopHandler);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user