ng-error-tracker
Version:
Angular library for securely capturing and sending logs.
434 lines (424 loc) • 18.9 kB
JavaScript
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