diff --git a/web/src/services/audioService.ts b/web/src/services/audioService.ts index d202bc2..4066a28 100755 --- a/web/src/services/audioService.ts +++ b/web/src/services/audioService.ts @@ -1,7 +1,7 @@ import WaveSurfer from "wavesurfer.js"; import { usePlayerStore } from "../stores/playerStore"; -// Log level enum +// Log level enum (will be exported at end of file) enum LogLevel { DEBUG = 0, INFO = 1, @@ -34,24 +34,56 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; private logLevel: LogLevel = LogLevel.ERROR; private playbackAttempts: number = 0; private readonly MAX_PLAYBACK_ATTEMPTS: number = 3; + private lastLogTime: number = 0; + private readonly LOG_THROTTLE_MS: number = 100; private constructor() { - // Check for debug mode from environment - // Check for debug mode from environment - const isDev = typeof window !== 'undefined' && window.location && window.location.hostname === 'localhost'; - if (isDev) { - this.setLogLevel(LogLevel.DEBUG); - this.log(LogLevel.INFO, 'AudioService initialized in DEVELOPMENT mode with debug logging'); - } else { - this.log(LogLevel.INFO, 'AudioService initialized'); + // Set appropriate log level based on environment + this.setLogLevel(this.detectLogLevel()); + + this.log(LogLevel.INFO, `AudioService initialized (log level: ${LogLevel[this.logLevel]})`); + } + + private detectLogLevel(): LogLevel { + try { + // Development environment: localhost or explicit debug mode + const isDevelopment = typeof window !== 'undefined' && + window.location && + (window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1'); + + // Check for debug query parameter (with safety checks) + const hasDebugParam = typeof window !== 'undefined' && + window.location && + window.location.search && + window.location.search.includes('audioDebug=true'); + + if (isDevelopment || hasDebugParam) { + return LogLevel.DEBUG; + } + } catch (error) { + // If anything goes wrong, default to WARN level + console.warn('Error detecting log level, defaulting to WARN:', error); } + + // Production: warn level to reduce noise + return LogLevel.WARN; } private log(level: LogLevel, message: string, ...args: unknown[]) { + // Skip if below current log level if (level < this.logLevel) return; + // Throttle rapid-fire logs to prevent console flooding + const now = Date.now(); + if (now - this.lastLogTime < this.LOG_THROTTLE_MS) { + return; // Skip this log to prevent spam + } + this.lastLogTime = now; + const prefix = `[AudioService:${LogLevel[level]}]`; + // Use appropriate console method based on log level switch(level) { case LogLevel.DEBUG: if (console.debug) { @@ -104,7 +136,8 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; // If same URL and we already have an instance, just update container reference if (this.currentUrl === url && this.wavesurfer) { - this.log(LogLevel.INFO, 'Reusing existing WaveSurfer instance for URL:', url); + // Implementation detail, only log in debug mode + this.log(LogLevel.DEBUG, 'Reusing existing WaveSurfer instance for URL:', url); try { // Check if container is different and needs updating const ws = this.wavesurfer as WaveSurferWithBackend; @@ -260,7 +293,8 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; } this.lastPlayTime = now; - this.log(LogLevel.INFO, 'AudioService.play called'); + // Only log play calls in debug mode to reduce noise + this.log(LogLevel.DEBUG, 'AudioService.play called'); try { // Ensure we have a valid audio context @@ -273,7 +307,8 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; } await this.wavesurfer.play(); - this.log(LogLevel.INFO, 'Playback started successfully'); + // Success logs are redundant, only log in debug mode + this.log(LogLevel.DEBUG, 'Playback started successfully'); this.playbackAttempts = 0; // Reset on success } catch (error) { this.playbackAttempts++; @@ -314,7 +349,8 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; return; } - this.log(LogLevel.INFO, 'AudioService.pause called'); + // Only log pause calls in debug mode to reduce noise + this.log(LogLevel.DEBUG, 'AudioService.pause called'); this.wavesurfer.pause(); } @@ -332,7 +368,8 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; } this.lastSeekTime = now; - this.log(LogLevel.INFO, "AudioService.seekTo called", { time }); + // Only log seek operations in debug mode to reduce noise + this.log(LogLevel.DEBUG, "AudioService.seekTo called", { time }); this.wavesurfer.setTime(time); } @@ -486,4 +523,4 @@ private readonly PLAY_DEBOUNCE_MS: number = 100; } export const audioService = AudioService.getInstance(); -export { AudioService }; // Export class for testing +export { AudioService, LogLevel }; // Export class and enum for testing diff --git a/web/tests/loggingOptimization.test.ts b/web/tests/loggingOptimization.test.ts new file mode 100644 index 0000000..37763cd --- /dev/null +++ b/web/tests/loggingOptimization.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AudioService, LogLevel } from '../src/services/audioService'; + +describe('AudioService Logging Optimization', () => { + let audioService: AudioService; + let consoleSpy: any; + + beforeEach(() => { + AudioService.resetInstance(); + + // Spy on console methods + consoleSpy = { + debug: vi.spyOn(console, 'debug').mockImplementation(() => {}), + info: vi.spyOn(console, 'info').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}) + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Environment-based Log Level Detection', () => { + it('should use DEBUG level in development environment (localhost)', () => { + // Mock localhost environment + const originalLocation = window.location; + delete (window as any).location; + window.location = { hostname: 'localhost' } as any; + + audioService = AudioService.getInstance(); + + expect(consoleSpy.info).toHaveBeenCalledWith( + expect.stringContaining('DEBUG') + ); + + window.location = originalLocation; + }); + + it('should use WARN level in production environment', () => { + // Mock production environment + const originalLocation = window.location; + delete (window as any).location; + window.location = { hostname: 'example.com' } as any; + + audioService = AudioService.getInstance(); + + expect(consoleSpy.info).toHaveBeenCalledWith( + expect.stringContaining('WARN') + ); + + window.location = originalLocation; + }); + + it('should use DEBUG level with audioDebug query parameter', () => { + // Mock production environment with debug parameter + const originalLocation = window.location; + delete (window as any).location; + window.location = { + hostname: 'example.com', + search: '?audioDebug=true' + } as any; + + audioService = AudioService.getInstance(); + + expect(consoleSpy.info).toHaveBeenCalledWith( + expect.stringContaining('log level: DEBUG') + ); + + window.location = originalLocation; + }); + }); + + describe('Log Throttling', () => { + it('should throttle rapid-fire log calls', () => { + // Mock development environment + const originalLocation = window.location; + delete (window as any).location; + window.location = { hostname: 'localhost' } as any; + + audioService = AudioService.getInstance(); + + // Call log multiple times rapidly + for (let i = 0; i < 10; i++) { + audioService['log'](LogLevel.DEBUG, `Test log ${i}`); + } + + // Should only log a few times due to throttling + expect(consoleSpy.debug).toHaveBeenCalled(); + // Should be called fewer times due to throttling + const callCount = consoleSpy.debug.mock.calls.length; + expect(callCount).toBeLessThanOrEqual(3); + + window.location = originalLocation; + }); + }); + + describe('Log Level Filtering', () => { + it('should filter out logs below current log level', () => { + // Mock production environment (WARN level) + const originalLocation = window.location; + delete (window as any).location; + window.location = { hostname: 'example.com' } as any; + + audioService = AudioService.getInstance(); + + // Try to log INFO level message in WARN environment + audioService['log'](LogLevel.INFO, 'This should not appear'); + + // Should not call console.info + expect(consoleSpy.info).not.toHaveBeenCalledWith( + expect.stringContaining('This should not appear') + ); + + // WARN level should appear + audioService['log'](LogLevel.WARN, 'This should appear'); + expect(consoleSpy.warn).toHaveBeenCalledWith( + expect.stringContaining('This should appear') + ); + + window.location = originalLocation; + }); + + it('should allow DEBUG logs in development environment', () => { + // Mock development environment + const originalLocation = window.location; + delete (window as any).location; + window.location = { hostname: 'localhost' } as any; + + audioService = AudioService.getInstance(); + + // DEBUG level should appear in development + audioService['log'](LogLevel.DEBUG, 'Debug message'); + expect(consoleSpy.debug).toHaveBeenCalledWith( + expect.stringContaining('Debug message') + ); + + window.location = originalLocation; + }); + }); + + describe('Verbose Log Reduction', () => { + it('should not log play/pause/seek calls in production', () => { + // Mock production environment + const originalLocation = window.location; + delete (window as any).location; + window.location = { hostname: 'example.com' } as any; + + audioService = AudioService.getInstance(); + + // These should not appear in production (INFO level, but production uses WARN) + audioService['log'](LogLevel.INFO, 'AudioService.play called'); + audioService['log'](LogLevel.INFO, 'AudioService.pause called'); + audioService['log'](LogLevel.INFO, 'AudioService.seekTo called'); + + // Should not call console.info for these + expect(consoleSpy.info).not.toHaveBeenCalledWith( + expect.stringContaining('AudioService.play called') + ); + expect(consoleSpy.info).not.toHaveBeenCalledWith( + expect.stringContaining('AudioService.pause called') + ); + expect(consoleSpy.info).not.toHaveBeenCalledWith( + expect.stringContaining('AudioService.seekTo called') + ); + + window.location = originalLocation; + }); + + it('should log errors in both environments', () => { + // Test in production environment + const originalLocation = window.location; + delete (window as any).location; + window.location = { hostname: 'example.com' } as any; + + audioService = AudioService.getInstance(); + + // Error logs should always appear + audioService['log'](LogLevel.ERROR, 'Critical error'); + expect(consoleSpy.error).toHaveBeenCalledWith( + expect.stringContaining('Critical error') + ); + + window.location = originalLocation; + }); + }); +}); \ No newline at end of file