UNPKG

ng-error-tracker

Version:

Angular library for securely capturing and sending logs.

434 lines (424 loc) 18.9 kB
import * as i0 from '@angular/core'; import { InjectionToken, Injectable, ErrorHandler, Inject } from '@angular/core'; import * as i1$1 from '@angular/common/http'; import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http'; import { catchError, map } from 'rxjs/operators'; import { EMPTY, timer, catchError as catchError$1, throwError } from 'rxjs'; import * as i1 from '@angular/router'; import { NavigationEnd, Router } from '@angular/router'; function sanitizeLog(text) { if (!text) return text; return text .replace(/[\w.-]+@[\w.-]+\.\w+/gi, '[email-protected]') .replace(/\b(?:\+\d{1,3}[-.\s]?)?(?:\(?\d{2,4}\)?[-.\s]?)?\d{3,5}[-.\s]?\d{3,5}[-.\s]?\d{0,5}\b/g, '[phone-protected]') .replace(/\b\d{8}[A-Za-z]\b/g, '[dni-protected]') .replace(/\b[XYZxyz]\d{7}[A-Za-z]\b/g, '[nie-protected]'); } const LOGGER_CONFIG = new InjectionToken('LOGGER_CONFIG'); class UserActionTrackerService { router; activatedRoute; actions = []; maxActions = 20; currentRoute = 'Unknown'; constructor(router, activatedRoute) { this.router = router; this.activatedRoute = activatedRoute; this.trackRouteChanges(); this.trackUserEvents(); } trackRouteChanges() { this.router.events.subscribe((event) => { if (event instanceof NavigationEnd) { let route = this.activatedRoute; while (route.firstChild) { route = route.firstChild; } route.data.subscribe((data) => { this.currentRoute = data['title'] || event.urlAfterRedirects; }); } }); } trackUserEvents() { document.addEventListener('click', this.trackEvent.bind(this)); document.addEventListener('touchstart', this.trackEvent.bind(this), { passive: true }); } trackEvent(event) { const targetElement = event.target; if (!targetElement) return; const tagName = targetElement.tagName.toLowerCase(); const name = targetElement.getAttribute('name') ?? ''; const id = targetElement.getAttribute('id') ?? ''; const classAttr = targetElement.getAttribute('class') || ''; const classes = classAttr ? `.${classAttr.replace(/\s+/g, '.')}` : ''; const text = (targetElement.textContent || '').trim().slice(0, 30); let message = `[${this.currentRoute}] ${tagName}${id ? `#${id}` : ''}${classes}`; if (name) message += ` [name="${name}"]`; if (text) message += ` (text="${text}")`; this.logAction(`Interaction: ${message}`); } logAction(action) { if (this.actions.length >= this.maxActions) { this.actions.shift(); } this.actions.push(`${new Date().toISOString()}: ${action}`); } getUserActions() { return [...this.actions]; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: UserActionTrackerService, deps: [{ token: i1.Router }, { token: i1.ActivatedRoute }], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: UserActionTrackerService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: UserActionTrackerService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: i1.Router }, { type: i1.ActivatedRoute }] }); class BuildIdService { async getAppBuildId() { const scripts = Array.from(document.getElementsByTagName('script')); const mainScript = scripts.find(({ src }) => /main(?:([-.])[a-zA-Z0-9]+)?\.js(?:\?.*)?$/.test(src)); if (mainScript) { const response = await fetch(mainScript.src); if (response.ok) { return await this.processMainJsStream(response.body.getReader()); } } console.warn('⚠️ Failed to fetch application main file.'); return 'unknown-build-id'; } async processMainJsStream(streamReader) { const CHUNK_SIZE = 512; let collectedChunks = []; let totalBytes = 0; while (totalBytes < CHUNK_SIZE) { const { done, value } = await streamReader.read(); if (done) break; if (value) { collectedChunks.push(value); totalBytes += value.length; } } const combinedBuffer = new Uint8Array(totalBytes); let offset = 0; for (const chunk of collectedChunks) { combinedBuffer.set(chunk, offset); offset += chunk.length; } return this.generateHashFromBuffer(combinedBuffer); } async generateHashFromBuffer(buffer) { return await window.crypto.subtle.digest('SHA-256', buffer).then(hashBuffer => { return Array.from(new Uint8Array(hashBuffer)) .map(byte => byte.toString(16).padStart(2, '0')) .join(''); }); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: BuildIdService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: BuildIdService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: BuildIdService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); class ErrorLoggerService { injector; userActionsTracker; buildIdService; config; router; http; publicKey = null; errorSet = new Set(); logQueue = []; maxStoredLogs = 20; canFlush = true; appBuildId = 'unknown-build-id'; STORAGE_LOGS_KEY = 'ng-error-tracker'; constructor(injector, handler, userActionsTracker, buildIdService, config, router) { this.injector = injector; this.userActionsTracker = userActionsTracker; this.buildIdService = buildIdService; this.config = config; this.router = router; this.http = new HttpClient(handler); if (this.config.enableLogging) { this.initService(); } } async initService() { this.appBuildId = !this.config.appBuildId ? await this.buildIdService.getAppBuildId() : this.config.appBuildId; console.log("✅ Signing logs with App Build ID:", this.appBuildId); this.loadStoredLogs(); if (this.config.publicKeyUrl) { this.loadPublicKey(); } else if (this.config.publicKey) { const binaryDer = this.base64ToArrayBuffer(this.config.publicKey); await this.importPublicKey(binaryDer); } this.router.events.subscribe((event) => { if (event instanceof NavigationEnd) { this.scheduleFlush(10000); } }); this.scheduleFlush(10000); } loadPublicKey() { if (!this.config.publicKeyUrl) return; this.http.get(this.config.publicKeyUrl, { responseType: 'text' }).pipe(catchError(() => { console.warn('⚠️ Could not retrieve the public key.'); return EMPTY; })).subscribe(async (base64Der) => { const binaryDer = this.base64ToArrayBuffer(base64Der); await this.importPublicKey(binaryDer); }); } async importPublicKey(binaryDer) { try { this.publicKey = await window.crypto.subtle.importKey("spki", binaryDer, { name: "RSA-OAEP", hash: "SHA-256" }, true, ["encrypt"]); console.log("✅ Public key imported successfully."); } catch (error) { console.error("❌ Error importing public key:", error); } } base64ToArrayBuffer(base64) { const binaryString = atob(base64); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes.buffer; } async handleError(error) { console.warn("⚠️ Error captured by ErrorLoggerService:", error); if (!this.config.enableLogging) { const defaultErrorHandler = this.injector.get(ErrorHandler, undefined); if (defaultErrorHandler && defaultErrorHandler !== this) { defaultErrorHandler.handleError(error); } return; } const logData = { severity: 'error', message: sanitizeLog(error.message || 'Unknown error'), stack: sanitizeLog(error.stack || ''), appName: this.config.appName, appEnv: this.config.appEnv, appBuildId: this.appBuildId, timestamp: new Date().toISOString(), userActions: this.userActionsTracker.getUserActions(), hash: '' }; logData['hash'] = await this.generateHash(logData); if (this.errorSet.has(logData['hash'])) { console.warn(`⚠️ Duplicate error detected: ${logData['hash']}, ignoring...`); const defaultErrorHandler = this.injector.get(ErrorHandler, undefined); if (defaultErrorHandler && defaultErrorHandler !== this) { defaultErrorHandler.handleError(error); } return; } this.errorSet.add(logData['hash']); this.logQueue.push(logData); this.storeLogs([logData]); this.scheduleFlush(30000); const defaultErrorHandler = this.injector.get(ErrorHandler, undefined); if (defaultErrorHandler && defaultErrorHandler !== this) { defaultErrorHandler.handleError(error); } return; } scheduleFlush(time) { if (!this.canFlush) return; this.canFlush = false; timer(time).subscribe(() => { this.canFlush = true; this.flushLogs(); }); } async flushLogs() { if (!this.config.enableLogging || this.logQueue.length === 0 || !this.canFlush) return; if (!this.publicKey) { console.warn('⚠️ No public key loaded yet. Logs remain queued.'); return; } const logsToSend = [...this.logQueue]; if (logsToSend.length === 0) return; const logsJson = JSON.stringify(logsToSend); try { const aesKey = await this.generateAESKey(); const { iv, cipher } = await this.encryptAES(logsJson, aesKey); const encryptedAESKey = await this.encryptAESKey(aesKey); if (!encryptedAESKey) { console.warn('⚠️ Could not encrypt AES key with RSA'); return; } const payload = { iv: this.arrayBufferToBase64(iv), cipher: this.arrayBufferToBase64(cipher), aesKey: encryptedAESKey }; let headers = new HttpHeaders({ 'Content-Type': 'application/json' }); if (this.config.apiKey) { headers = headers.set('Authorization', `Bearer ${this.config.apiKey}`); } this.http.post(this.config.apiUrl, payload, { headers, responseType: 'text', observe: 'response' }).pipe(map(response => response.body || ''), catchError((err) => { console.warn('⚠️ Could not send the log. They will remain stored until the next attempt.', err?.message); return EMPTY; })).subscribe((responseBody) => { console.log('✅ Logs sent successfully (AES-256-GCM + RSA-OAEP-SHA-256).'); if (responseBody.trim() === '') { console.log('📥 Server response is empty.'); } else { console.log('📥 Server response:', responseBody); } this.removeStoredLogs(logsToSend); }); } catch (err) { console.warn('⚠️ Could not send the log. They will remain stored until the next attempt.', err); } } async generateAESKey() { return window.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); } async encryptAES(plaintext, key) { const encoder = new TextEncoder(); const data = encoder.encode(plaintext); const iv = window.crypto.getRandomValues(new Uint8Array(12)); const cipher = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); return { iv, cipher }; } async encryptAESKey(aesKey) { const rawKey = await window.crypto.subtle.exportKey('raw', aesKey); if (!this.publicKey) return false; const encryptedKey = await window.crypto.subtle.encrypt({ name: "RSA-OAEP" }, this.publicKey, rawKey); return this.arrayBufferToBase64(encryptedKey); } arrayBufferToBase64(buffer) { return btoa(String.fromCharCode(...new Uint8Array(buffer))); } loadStoredLogs() { let storedLogs = JSON.parse(localStorage.getItem(this.STORAGE_LOGS_KEY) || '[]'); if (storedLogs.length > 0) { storedLogs = [...storedLogs].slice(-this.maxStoredLogs); this.logQueue = []; this.errorSet = new Set(); storedLogs.forEach((log) => { if (!this.errorSet.has(log['hash'])) { this.errorSet.add(log['hash']); this.logQueue.push(log); } }); } else { this.logQueue = []; this.errorSet = new Set(); } } storeLogs(logs) { const storedLogs = JSON.parse(localStorage.getItem(this.STORAGE_LOGS_KEY) || '[]'); const merged = [...storedLogs, ...logs] .slice(-this.maxStoredLogs) .filter((log, index, self) => self.findIndex(l => l.hash === log.hash) === index); localStorage.setItem(this.STORAGE_LOGS_KEY, JSON.stringify(merged)); this.logQueue = merged; this.errorSet = new Set(); merged.forEach((log) => { this.errorSet.add(log['hash']); }); } removeStoredLogs(logsSent) { const storedLogs = JSON.parse(localStorage.getItem(this.STORAGE_LOGS_KEY) || '[]'); const remaining = storedLogs.filter((storedLog) => !logsSent.some((sentLog) => sentLog.hash === storedLog.hash)); localStorage.setItem(this.STORAGE_LOGS_KEY, JSON.stringify(remaining)); this.logQueue = remaining; this.errorSet = new Set(); remaining.forEach((log) => { this.errorSet.add(log['hash']); }); } async generateHash(logData) { const encoder = new TextEncoder(); const data = encoder.encode(`${logData.appName}-${logData.appEnv}-${logData.appBuildId}-${logData.message || 'Unknown error'}-${logData.stack || ''}`); const hashBuffer = await window.crypto.subtle.digest('SHA-256', data); return this.arrayBufferToHex(hashBuffer); } arrayBufferToHex(buffer) { return Array.from(new Uint8Array(buffer)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: ErrorLoggerService, deps: [{ token: i0.Injector }, { token: i1$1.HttpBackend }, { token: UserActionTrackerService }, { token: BuildIdService }, { token: LOGGER_CONFIG }, { token: Router }], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: ErrorLoggerService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: ErrorLoggerService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: i0.Injector }, { type: i1$1.HttpBackend }, { type: UserActionTrackerService }, { type: BuildIdService }, { type: undefined, decorators: [{ type: Inject, args: [LOGGER_CONFIG] }] }, { type: i1.Router, decorators: [{ type: Inject, args: [Router] }] }] }); class ErrorLoggerInterceptor { config; errorLogger; constructor(config, errorLogger) { this.config = config; this.errorLogger = errorLogger; console.log('✅ Logger interceptor loaded.'); } intercept(req, next) { if (!this.config.enableLogging) { return next.handle(req); } else { return next.handle(req).pipe(catchError$1((error) => { if (!req.url.includes(this.config.apiUrl)) { if (error instanceof HttpErrorResponse) { const standardError = new Error(`HTTP ${error.status}: ${error.message}`); standardError.stack = error.error?.stack || error.message; this.errorLogger.handleError(standardError); } } return throwError(() => error); })); } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: ErrorLoggerInterceptor, deps: [{ token: LOGGER_CONFIG }, { token: ErrorLoggerService }], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: ErrorLoggerInterceptor }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.3", ngImport: i0, type: ErrorLoggerInterceptor, decorators: [{ type: Injectable }], ctorParameters: () => [{ type: undefined, decorators: [{ type: Inject, args: [LOGGER_CONFIG] }] }, { type: ErrorLoggerService }] }); function provideErrorTracker(config) { return [ { provide: LOGGER_CONFIG, useValue: config }, { provide: ErrorLoggerService, useClass: ErrorLoggerService }, { provide: ErrorHandler, useExisting: ErrorLoggerService }, ]; } /** * Generated bundle index. Do not edit. */ export { BuildIdService, ErrorLoggerInterceptor, ErrorLoggerService, LOGGER_CONFIG, UserActionTrackerService, provideErrorTracker, sanitizeLog }; //# sourceMappingURL=ng-error-tracker.mjs.map