698 lines
17 KiB
TypeScript
698 lines
17 KiB
TypeScript
import { logger } from './logging.js';
|
|
import { getServiceWorkerBackend } from './init.js';
|
|
|
|
/**
|
|
* Interface for cache metrics
|
|
*/
|
|
export interface ICacheMetrics {
|
|
hits: number;
|
|
misses: number;
|
|
errors: number;
|
|
bytesServedFromCache: number;
|
|
bytesFetched: number;
|
|
averageResponseTime: number;
|
|
}
|
|
|
|
/**
|
|
* Interface for per-resource tracking
|
|
*/
|
|
export interface ICachedResource {
|
|
url: string;
|
|
domain: string;
|
|
contentType: string;
|
|
size: number;
|
|
hitCount: number;
|
|
missCount: number;
|
|
lastAccessed: number;
|
|
cachedAt: number;
|
|
}
|
|
|
|
/**
|
|
* Interface for domain statistics
|
|
*/
|
|
export interface IDomainStats {
|
|
domain: string;
|
|
totalResources: number;
|
|
totalSize: number;
|
|
totalHits: number;
|
|
totalMisses: number;
|
|
hitRate: number;
|
|
}
|
|
|
|
/**
|
|
* Interface for content-type statistics
|
|
*/
|
|
export interface IContentTypeStats {
|
|
contentType: string;
|
|
totalResources: number;
|
|
totalSize: number;
|
|
totalHits: number;
|
|
totalMisses: number;
|
|
hitRate: number;
|
|
}
|
|
|
|
/**
|
|
* Interface for network metrics
|
|
*/
|
|
export interface INetworkMetrics {
|
|
totalRequests: number;
|
|
successfulRequests: number;
|
|
failedRequests: number;
|
|
timeouts: number;
|
|
averageLatency: number;
|
|
totalBytesTransferred: number;
|
|
}
|
|
|
|
/**
|
|
* Interface for update metrics
|
|
*/
|
|
export interface IUpdateMetrics {
|
|
totalChecks: number;
|
|
successfulChecks: number;
|
|
failedChecks: number;
|
|
updatesFound: number;
|
|
updatesApplied: number;
|
|
lastCheckTimestamp: number;
|
|
lastUpdateTimestamp: number;
|
|
}
|
|
|
|
/**
|
|
* Interface for connection metrics
|
|
*/
|
|
export interface IConnectionMetrics {
|
|
connectedClients: number;
|
|
totalConnectionAttempts: number;
|
|
successfulConnections: number;
|
|
failedConnections: number;
|
|
}
|
|
|
|
/**
|
|
* Interface for speedtest metrics
|
|
*/
|
|
export interface ISpeedtestMetrics {
|
|
lastDownloadSpeedMbps: number;
|
|
lastUploadSpeedMbps: number;
|
|
lastLatencyMs: number;
|
|
lastTestTimestamp: number;
|
|
testCount: number;
|
|
isOnline: boolean;
|
|
}
|
|
|
|
/**
|
|
* Combined metrics interface
|
|
*/
|
|
export interface IServiceWorkerMetrics {
|
|
cache: ICacheMetrics;
|
|
network: INetworkMetrics;
|
|
update: IUpdateMetrics;
|
|
connection: IConnectionMetrics;
|
|
speedtest: ISpeedtestMetrics;
|
|
startTime: number;
|
|
uptime: number;
|
|
}
|
|
|
|
/**
|
|
* Response time entry for calculating averages
|
|
*/
|
|
interface IResponseTimeEntry {
|
|
url: string;
|
|
duration: number;
|
|
timestamp: number;
|
|
}
|
|
|
|
/**
|
|
* Metrics collector for service worker observability
|
|
*/
|
|
export class MetricsCollector {
|
|
private static instance: MetricsCollector;
|
|
|
|
// Cache metrics
|
|
private cacheHits = 0;
|
|
private cacheMisses = 0;
|
|
private cacheErrors = 0;
|
|
private bytesServedFromCache = 0;
|
|
private bytesFetched = 0;
|
|
|
|
// Network metrics
|
|
private totalRequests = 0;
|
|
private successfulRequests = 0;
|
|
private failedRequests = 0;
|
|
private timeouts = 0;
|
|
private totalBytesTransferred = 0;
|
|
|
|
// Update metrics
|
|
private totalUpdateChecks = 0;
|
|
private successfulUpdateChecks = 0;
|
|
private failedUpdateChecks = 0;
|
|
private updatesFound = 0;
|
|
private updatesApplied = 0;
|
|
private lastCheckTimestamp = 0;
|
|
private lastUpdateTimestamp = 0;
|
|
|
|
// Connection metrics
|
|
private connectedClients = 0;
|
|
private totalConnectionAttempts = 0;
|
|
private successfulConnections = 0;
|
|
private failedConnections = 0;
|
|
|
|
// Speedtest metrics
|
|
private lastDownloadSpeedMbps = 0;
|
|
private lastUploadSpeedMbps = 0;
|
|
private lastLatencyMs = 0;
|
|
private lastSpeedtestTimestamp = 0;
|
|
private speedtestCount = 0;
|
|
private isOnline = true;
|
|
|
|
// Response time tracking
|
|
private responseTimes: IResponseTimeEntry[] = [];
|
|
private readonly maxResponseTimeEntries = 1000;
|
|
private readonly responseTimeWindow = 5 * 60 * 1000; // 5 minutes
|
|
|
|
// Per-resource tracking
|
|
private resourceStats: Map<string, ICachedResource> = new Map();
|
|
private readonly maxResourceEntries = 500;
|
|
|
|
// Start time
|
|
private readonly startTime: number;
|
|
|
|
private constructor() {
|
|
this.startTime = Date.now();
|
|
}
|
|
|
|
/**
|
|
* Triggers a push metrics update to all connected clients (throttled in backend)
|
|
*/
|
|
private triggerPushUpdate(): void {
|
|
try {
|
|
const backend = getServiceWorkerBackend();
|
|
if (backend) {
|
|
backend.pushMetricsUpdate();
|
|
}
|
|
} catch (error) {
|
|
// Silently ignore - push is best-effort
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the singleton instance
|
|
*/
|
|
public static getInstance(): MetricsCollector {
|
|
if (!MetricsCollector.instance) {
|
|
MetricsCollector.instance = new MetricsCollector();
|
|
}
|
|
return MetricsCollector.instance;
|
|
}
|
|
|
|
// ===================
|
|
// Cache Metrics
|
|
// ===================
|
|
|
|
public recordCacheHit(url: string, bytes: number = 0): void {
|
|
this.cacheHits++;
|
|
this.bytesServedFromCache += bytes;
|
|
logger.log('note', `[Metrics] Cache hit: ${url} (${bytes} bytes)`);
|
|
this.triggerPushUpdate();
|
|
}
|
|
|
|
public recordCacheMiss(url: string): void {
|
|
this.cacheMisses++;
|
|
logger.log('note', `[Metrics] Cache miss: ${url}`);
|
|
this.triggerPushUpdate();
|
|
}
|
|
|
|
public recordCacheError(url: string, error?: string): void {
|
|
this.cacheErrors++;
|
|
logger.log('warn', `[Metrics] Cache error: ${url} - ${error || 'unknown'}`);
|
|
}
|
|
|
|
public recordBytesFetched(bytes: number): void {
|
|
this.bytesFetched += bytes;
|
|
}
|
|
|
|
// ===================
|
|
// Network Metrics
|
|
// ===================
|
|
|
|
public recordRequest(_url: string): void {
|
|
this.totalRequests++;
|
|
}
|
|
|
|
public recordRequestSuccess(url: string, duration: number, bytes: number = 0): void {
|
|
this.successfulRequests++;
|
|
this.totalBytesTransferred += bytes;
|
|
this.recordResponseTime(url, duration);
|
|
this.triggerPushUpdate();
|
|
}
|
|
|
|
public recordRequestFailure(url: string, error?: string): void {
|
|
this.failedRequests++;
|
|
logger.log('warn', `[Metrics] Request failed: ${url} - ${error || 'unknown'}`);
|
|
this.triggerPushUpdate();
|
|
}
|
|
|
|
public recordTimeout(url: string, duration: number): void {
|
|
this.timeouts++;
|
|
logger.log('warn', `[Metrics] Request timeout: ${url} after ${duration}ms`);
|
|
}
|
|
|
|
// ===================
|
|
// Update Metrics
|
|
// ===================
|
|
|
|
public recordUpdateCheck(success: boolean): void {
|
|
this.totalUpdateChecks++;
|
|
this.lastCheckTimestamp = Date.now();
|
|
if (success) {
|
|
this.successfulUpdateChecks++;
|
|
} else {
|
|
this.failedUpdateChecks++;
|
|
}
|
|
}
|
|
|
|
public recordUpdateFound(): void {
|
|
this.updatesFound++;
|
|
}
|
|
|
|
public recordUpdateApplied(): void {
|
|
this.updatesApplied++;
|
|
this.lastUpdateTimestamp = Date.now();
|
|
}
|
|
|
|
// ===================
|
|
// Connection Metrics
|
|
// ===================
|
|
|
|
public recordConnectionAttempt(): void {
|
|
this.totalConnectionAttempts++;
|
|
}
|
|
|
|
public recordConnectionSuccess(): void {
|
|
this.successfulConnections++;
|
|
this.connectedClients++;
|
|
}
|
|
|
|
public recordConnectionFailure(): void {
|
|
this.failedConnections++;
|
|
}
|
|
|
|
public recordClientDisconnect(): void {
|
|
this.connectedClients = Math.max(0, this.connectedClients - 1);
|
|
}
|
|
|
|
public setConnectedClients(count: number): void {
|
|
this.connectedClients = count;
|
|
}
|
|
|
|
// ===================
|
|
// Speedtest Metrics
|
|
// ===================
|
|
|
|
public recordSpeedtest(type: 'download' | 'upload' | 'latency', value: number): void {
|
|
this.speedtestCount++;
|
|
this.lastSpeedtestTimestamp = Date.now();
|
|
this.isOnline = true;
|
|
|
|
switch (type) {
|
|
case 'download':
|
|
this.lastDownloadSpeedMbps = value;
|
|
logger.log('info', `[Metrics] Speedtest download: ${value.toFixed(2)} Mbps`);
|
|
break;
|
|
case 'upload':
|
|
this.lastUploadSpeedMbps = value;
|
|
logger.log('info', `[Metrics] Speedtest upload: ${value.toFixed(2)} Mbps`);
|
|
break;
|
|
case 'latency':
|
|
this.lastLatencyMs = value;
|
|
logger.log('info', `[Metrics] Speedtest latency: ${value.toFixed(0)} ms`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
public setOnlineStatus(online: boolean): void {
|
|
this.isOnline = online;
|
|
logger.log('info', `[Metrics] Online status: ${online ? 'online' : 'offline'}`);
|
|
}
|
|
|
|
public getSpeedtestMetrics(): ISpeedtestMetrics {
|
|
return {
|
|
lastDownloadSpeedMbps: this.lastDownloadSpeedMbps,
|
|
lastUploadSpeedMbps: this.lastUploadSpeedMbps,
|
|
lastLatencyMs: this.lastLatencyMs,
|
|
lastTestTimestamp: this.lastSpeedtestTimestamp,
|
|
testCount: this.speedtestCount,
|
|
isOnline: this.isOnline,
|
|
};
|
|
}
|
|
|
|
// ===================
|
|
// Response Time Tracking
|
|
// ===================
|
|
|
|
private recordResponseTime(url: string, duration: number): void {
|
|
const entry: IResponseTimeEntry = {
|
|
url,
|
|
duration,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
this.responseTimes.push(entry);
|
|
|
|
// Trim old entries
|
|
this.cleanupResponseTimes();
|
|
}
|
|
|
|
private cleanupResponseTimes(): void {
|
|
const cutoff = Date.now() - this.responseTimeWindow;
|
|
|
|
// Remove old entries
|
|
this.responseTimes = this.responseTimes.filter(
|
|
(entry) => entry.timestamp >= cutoff
|
|
);
|
|
|
|
// Keep within max size
|
|
if (this.responseTimes.length > this.maxResponseTimeEntries) {
|
|
this.responseTimes = this.responseTimes.slice(-this.maxResponseTimeEntries);
|
|
}
|
|
}
|
|
|
|
private calculateAverageResponseTime(): number {
|
|
if (this.responseTimes.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
const sum = this.responseTimes.reduce((acc, entry) => acc + entry.duration, 0);
|
|
return Math.round(sum / this.responseTimes.length);
|
|
}
|
|
|
|
private calculateAverageLatency(): number {
|
|
// Same as response time for now
|
|
return this.calculateAverageResponseTime();
|
|
}
|
|
|
|
// ===================
|
|
// Metrics Retrieval
|
|
// ===================
|
|
|
|
/**
|
|
* Gets all metrics
|
|
*/
|
|
public getMetrics(): IServiceWorkerMetrics {
|
|
const now = Date.now();
|
|
this.cleanupResponseTimes();
|
|
|
|
return {
|
|
cache: {
|
|
hits: this.cacheHits,
|
|
misses: this.cacheMisses,
|
|
errors: this.cacheErrors,
|
|
bytesServedFromCache: this.bytesServedFromCache,
|
|
bytesFetched: this.bytesFetched,
|
|
averageResponseTime: this.calculateAverageResponseTime(),
|
|
},
|
|
network: {
|
|
totalRequests: this.totalRequests,
|
|
successfulRequests: this.successfulRequests,
|
|
failedRequests: this.failedRequests,
|
|
timeouts: this.timeouts,
|
|
averageLatency: this.calculateAverageLatency(),
|
|
totalBytesTransferred: this.totalBytesTransferred,
|
|
},
|
|
update: {
|
|
totalChecks: this.totalUpdateChecks,
|
|
successfulChecks: this.successfulUpdateChecks,
|
|
failedChecks: this.failedUpdateChecks,
|
|
updatesFound: this.updatesFound,
|
|
updatesApplied: this.updatesApplied,
|
|
lastCheckTimestamp: this.lastCheckTimestamp,
|
|
lastUpdateTimestamp: this.lastUpdateTimestamp,
|
|
},
|
|
connection: {
|
|
connectedClients: this.connectedClients,
|
|
totalConnectionAttempts: this.totalConnectionAttempts,
|
|
successfulConnections: this.successfulConnections,
|
|
failedConnections: this.failedConnections,
|
|
},
|
|
speedtest: {
|
|
lastDownloadSpeedMbps: this.lastDownloadSpeedMbps,
|
|
lastUploadSpeedMbps: this.lastUploadSpeedMbps,
|
|
lastLatencyMs: this.lastLatencyMs,
|
|
lastTestTimestamp: this.lastSpeedtestTimestamp,
|
|
testCount: this.speedtestCount,
|
|
isOnline: this.isOnline,
|
|
},
|
|
startTime: this.startTime,
|
|
uptime: now - this.startTime,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Gets cache hit rate as a percentage
|
|
*/
|
|
public getCacheHitRate(): number {
|
|
const total = this.cacheHits + this.cacheMisses;
|
|
if (total === 0) {
|
|
return 0;
|
|
}
|
|
return Math.round((this.cacheHits / total) * 100);
|
|
}
|
|
|
|
/**
|
|
* Gets network success rate as a percentage
|
|
*/
|
|
public getNetworkSuccessRate(): number {
|
|
if (this.totalRequests === 0) {
|
|
return 100;
|
|
}
|
|
return Math.round((this.successfulRequests / this.totalRequests) * 100);
|
|
}
|
|
|
|
/**
|
|
* Resets all metrics
|
|
*/
|
|
public reset(): void {
|
|
this.cacheHits = 0;
|
|
this.cacheMisses = 0;
|
|
this.cacheErrors = 0;
|
|
this.bytesServedFromCache = 0;
|
|
this.bytesFetched = 0;
|
|
|
|
this.totalRequests = 0;
|
|
this.successfulRequests = 0;
|
|
this.failedRequests = 0;
|
|
this.timeouts = 0;
|
|
this.totalBytesTransferred = 0;
|
|
|
|
this.totalUpdateChecks = 0;
|
|
this.successfulUpdateChecks = 0;
|
|
this.failedUpdateChecks = 0;
|
|
this.updatesFound = 0;
|
|
this.updatesApplied = 0;
|
|
this.lastCheckTimestamp = 0;
|
|
this.lastUpdateTimestamp = 0;
|
|
|
|
this.totalConnectionAttempts = 0;
|
|
this.successfulConnections = 0;
|
|
this.failedConnections = 0;
|
|
|
|
this.lastDownloadSpeedMbps = 0;
|
|
this.lastUploadSpeedMbps = 0;
|
|
this.lastLatencyMs = 0;
|
|
this.lastSpeedtestTimestamp = 0;
|
|
this.speedtestCount = 0;
|
|
// Note: isOnline is not reset as it reflects current state
|
|
|
|
this.responseTimes = [];
|
|
this.resourceStats.clear();
|
|
|
|
logger.log('info', '[Metrics] All metrics reset');
|
|
}
|
|
|
|
/**
|
|
* Gets a summary string for logging
|
|
*/
|
|
public getSummary(): string {
|
|
const metrics = this.getMetrics();
|
|
return [
|
|
`Cache: ${this.getCacheHitRate()}% hit rate (${metrics.cache.hits}/${metrics.cache.hits + metrics.cache.misses})`,
|
|
`Network: ${this.getNetworkSuccessRate()}% success (${metrics.network.successfulRequests}/${metrics.network.totalRequests})`,
|
|
`Updates: ${metrics.update.updatesFound} found, ${metrics.update.updatesApplied} applied`,
|
|
`Uptime: ${Math.round(metrics.uptime / 1000)}s`,
|
|
].join(' | ');
|
|
}
|
|
|
|
// ===================
|
|
// Per-Resource Tracking
|
|
// ===================
|
|
|
|
/**
|
|
* Extracts domain from URL
|
|
*/
|
|
private extractDomain(url: string): string {
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
return parsedUrl.hostname;
|
|
} catch {
|
|
return 'unknown';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Records a resource access (cache hit or miss) with details
|
|
*/
|
|
public recordResourceAccess(
|
|
url: string,
|
|
isHit: boolean,
|
|
contentType: string = 'unknown',
|
|
size: number = 0
|
|
): void {
|
|
const now = Date.now();
|
|
const domain = this.extractDomain(url);
|
|
|
|
let resource = this.resourceStats.get(url);
|
|
|
|
if (!resource) {
|
|
resource = {
|
|
url,
|
|
domain,
|
|
contentType,
|
|
size,
|
|
hitCount: 0,
|
|
missCount: 0,
|
|
lastAccessed: now,
|
|
cachedAt: now,
|
|
};
|
|
this.resourceStats.set(url, resource);
|
|
}
|
|
|
|
// Update resource stats
|
|
if (isHit) {
|
|
resource.hitCount++;
|
|
} else {
|
|
resource.missCount++;
|
|
}
|
|
resource.lastAccessed = now;
|
|
|
|
// Update content-type and size if provided (may come from response headers)
|
|
if (contentType !== 'unknown') {
|
|
resource.contentType = contentType;
|
|
}
|
|
if (size > 0) {
|
|
resource.size = size;
|
|
}
|
|
|
|
// Trim old entries if needed
|
|
this.cleanupResourceStats();
|
|
}
|
|
|
|
/**
|
|
* Cleans up old resource entries to prevent memory bloat
|
|
*/
|
|
private cleanupResourceStats(): void {
|
|
if (this.resourceStats.size <= this.maxResourceEntries) {
|
|
return;
|
|
}
|
|
|
|
// Convert to array and sort by lastAccessed (oldest first)
|
|
const entries = Array.from(this.resourceStats.entries())
|
|
.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed);
|
|
|
|
// Remove oldest entries until we're under the limit
|
|
const toRemove = entries.slice(0, entries.length - this.maxResourceEntries);
|
|
for (const [url] of toRemove) {
|
|
this.resourceStats.delete(url);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets all cached resources
|
|
*/
|
|
public getCachedResources(): ICachedResource[] {
|
|
return Array.from(this.resourceStats.values());
|
|
}
|
|
|
|
/**
|
|
* Gets domain statistics
|
|
*/
|
|
public getDomainStats(): IDomainStats[] {
|
|
const domainMap = new Map<string, IDomainStats>();
|
|
|
|
for (const resource of this.resourceStats.values()) {
|
|
let stats = domainMap.get(resource.domain);
|
|
|
|
if (!stats) {
|
|
stats = {
|
|
domain: resource.domain,
|
|
totalResources: 0,
|
|
totalSize: 0,
|
|
totalHits: 0,
|
|
totalMisses: 0,
|
|
hitRate: 0,
|
|
};
|
|
domainMap.set(resource.domain, stats);
|
|
}
|
|
|
|
stats.totalResources++;
|
|
stats.totalSize += resource.size;
|
|
stats.totalHits += resource.hitCount;
|
|
stats.totalMisses += resource.missCount;
|
|
}
|
|
|
|
// Calculate hit rates
|
|
for (const stats of domainMap.values()) {
|
|
const total = stats.totalHits + stats.totalMisses;
|
|
stats.hitRate = total > 0 ? Math.round((stats.totalHits / total) * 100) : 0;
|
|
}
|
|
|
|
return Array.from(domainMap.values());
|
|
}
|
|
|
|
/**
|
|
* Gets content-type statistics
|
|
*/
|
|
public getContentTypeStats(): IContentTypeStats[] {
|
|
const typeMap = new Map<string, IContentTypeStats>();
|
|
|
|
for (const resource of this.resourceStats.values()) {
|
|
// Normalize content-type (extract base type)
|
|
const baseType = resource.contentType.split(';')[0].trim() || 'unknown';
|
|
|
|
let stats = typeMap.get(baseType);
|
|
|
|
if (!stats) {
|
|
stats = {
|
|
contentType: baseType,
|
|
totalResources: 0,
|
|
totalSize: 0,
|
|
totalHits: 0,
|
|
totalMisses: 0,
|
|
hitRate: 0,
|
|
};
|
|
typeMap.set(baseType, stats);
|
|
}
|
|
|
|
stats.totalResources++;
|
|
stats.totalSize += resource.size;
|
|
stats.totalHits += resource.hitCount;
|
|
stats.totalMisses += resource.missCount;
|
|
}
|
|
|
|
// Calculate hit rates
|
|
for (const stats of typeMap.values()) {
|
|
const total = stats.totalHits + stats.totalMisses;
|
|
stats.hitRate = total > 0 ? Math.round((stats.totalHits / total) * 100) : 0;
|
|
}
|
|
|
|
return Array.from(typeMap.values());
|
|
}
|
|
|
|
/**
|
|
* Gets resource count
|
|
*/
|
|
public getResourceCount(): number {
|
|
return this.resourceStats.size;
|
|
}
|
|
}
|
|
|
|
// Export singleton getter for convenience
|
|
export const getMetricsCollector = (): MetricsCollector => MetricsCollector.getInstance();
|