traffic-analyzer.js•12.1 kB
/**
* Traffic Analyzer
* Captures, stores, and analyzes proxy traffic
*/
import fs from 'fs/promises';
import path from 'path';
import assert from 'assert';
export class TrafficAnalyzer {
constructor(options = {}) {
// Pre-conditions
assert(typeof options === 'object', 'options must be an object');
this.entries = [];
this.maxEntries = options.maxEntries || 10000;
this.enablePersistence = options.enablePersistence || false;
this.persistenceFile = options.persistenceFile || './traffic-log.json';
this.stats = {
totalRequests: 0,
totalResponseTime: 0,
domainsTracked: new Set(),
startTime: new Date()
};
// Post-conditions
assert(Array.isArray(this.entries), 'entries must be an array');
assert(typeof this.maxEntries === 'number' && this.maxEntries > 0, 'maxEntries must be a positive number');
}
/**
* Add traffic entry
* @param {Object} entry - Traffic entry
*/
addEntry(entry) {
// Pre-conditions
assert(entry, 'entry is required');
assert(typeof entry === 'object', 'entry must be an object');
assert(typeof entry.timestamp === 'string', 'entry.timestamp must be a string');
assert(typeof entry.url === 'string', 'entry.url must be a string');
assert(typeof entry.method === 'string', 'entry.method must be a string');
// Ensure required fields
const completeEntry = {
id: this._generateId(),
timestamp: entry.timestamp || new Date().toISOString(),
url: entry.url,
method: entry.method || 'GET',
domain: entry.domain,
statusCode: entry.statusCode,
responseTime: entry.responseTime || 0,
headers: entry.headers,
tunneled: entry.tunneled || false,
bodyCaptured: entry.bodyCaptured || false,
note: entry.note
};
this.entries.push(completeEntry);
// Update stats
this.stats.totalRequests++;
this.stats.totalResponseTime += completeEntry.responseTime;
this.stats.domainsTracked.add(completeEntry.domain);
// Trim entries if exceeding limit
if (this.entries.length > this.maxEntries) {
this.entries.shift();
}
// Persist if enabled
if (this.enablePersistence) {
this._persistEntries().catch(console.error);
}
// Post-conditions
assert(this.entries.length > 0, 'Entry should have been added to entries array');
assert(this.entries[this.entries.length - 1].id === completeEntry.id, 'Last entry should match the added entry');
assert(this.stats.totalRequests > 0, 'Total requests should have been incremented');
console.log(`✅ Traffic entry added: ${completeEntry.id} (${completeEntry.method} ${completeEntry.url})`);
return completeEntry.id;
}
/**
* Get entries with filtering
* @param {Object} options - Filter options
* @returns {Array} Filtered entries
*/
getEntries(options = {}) {
let filtered = [...this.entries];
// Filter by domain
if (options.domain) {
filtered = filtered.filter(entry =>
entry.domain.includes(options.domain)
);
}
// Filter by method
if (options.method) {
filtered = filtered.filter(entry =>
entry.method === options.method.toUpperCase()
);
}
// Filter by time
if (options.since) {
const sinceTime = new Date(options.since).getTime();
filtered = filtered.filter(entry =>
new Date(entry.timestamp).getTime() >= sinceTime
);
}
// Sort by timestamp (newest first)
filtered.sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
// Limit results
if (options.limit) {
filtered = filtered.slice(0, options.limit);
}
return filtered;
}
/**
* Get all entries
* @returns {Array} All entries
*/
getAllEntries() {
return [...this.entries];
}
/**
* Get entry count
* @returns {number} Number of entries
*/
getEntryCount() {
return this.entries.length;
}
/**
* Clear entries
* @param {string} domain - Optional domain filter
* @returns {number} Number of cleared entries
*/
clearEntries(domain = null) {
let cleared = 0;
if (domain) {
const originalLength = this.entries.length;
this.entries = this.entries.filter(entry =>
!entry.domain.includes(domain)
);
cleared = originalLength - this.entries.length;
} else {
cleared = this.entries.length;
this.entries = [];
this.stats = {
totalRequests: 0,
totalResponseTime: 0,
domainsTracked: new Set(),
startTime: new Date()
};
}
return cleared;
}
/**
* Analyze traffic patterns
* @param {Object} options - Analysis options
* @returns {Object} Analysis results
*/
analyzeTraffic(options = {}) {
const entries = this.getEntries({
domain: options.domain,
since: this._getTimeframeSince(options.timeframe || '24h')
});
if (entries.length === 0) {
return {
summary: {
totalRequests: 0,
uniqueDomains: 0,
timeframe: options.timeframe || '24h'
}
};
}
const analysis = {
summary: {
totalRequests: entries.length,
uniqueDomains: new Set(entries.map(e => e.domain)).size,
timeframe: options.timeframe || '24h',
avgResponseTime: this._calculateAverage(entries.map(e => e.responseTime)),
errorRate: entries.filter(e => e.statusCode >= 400).length / entries.length
},
grouped: this._groupEntries(entries, options.groupBy || 'domain')
};
return analysis;
}
/**
* Export traffic as HAR format
* @param {Object} options - Export options
* @returns {Object} Export info
*/
async exportHAR(options = {}) {
const entries = this.getEntries({
domain: options.domain,
since: options.since
});
const har = {
log: {
version: "1.2",
creator: {
name: "Web Proxy MCP Server",
version: "1.0"
},
pages: [],
entries: entries.map(entry => this._convertToHAREntry(entry))
}
};
const filename = options.filename ?
`${options.filename}.har` :
`traffic-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.har`;
const filepath = path.resolve(filename);
const content = JSON.stringify(har, null, 2);
await fs.writeFile(filepath, content);
return {
filename,
filepath,
entryCount: entries.length,
fileSize: content.length
};
}
/**
* Get traffic statistics
* @returns {Object} Traffic statistics
*/
getStats() {
return {
totalEntries: this.entries.length,
totalRequests: this.stats.totalRequests,
uniqueDomains: this.stats.domainsTracked.size,
averageResponseTime: this.stats.totalRequests > 0 ?
this.stats.totalResponseTime / this.stats.totalRequests : 0,
uptimeSeconds: Math.floor((Date.now() - this.stats.startTime.getTime()) / 1000),
recentActivity: this._getRecentActivity()
};
}
/**
* Load persisted entries
*/
async loadPersistedEntries() {
if (!this.enablePersistence) return;
try {
const data = await fs.readFile(this.persistenceFile, 'utf-8');
const entries = JSON.parse(data);
if (Array.isArray(entries)) {
this.entries = entries.slice(-this.maxEntries);
console.log(`Loaded ${this.entries.length} persisted traffic entries`);
}
} catch (error) {
// File might not exist or be corrupted, start fresh
console.log('No persisted traffic entries found, starting fresh');
}
}
/**
* Generate unique ID for entry
* @private
*/
_generateId() {
return `entry-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Persist entries to file
* @private
*/
async _persistEntries() {
try {
await fs.writeFile(
this.persistenceFile,
JSON.stringify(this.entries, null, 2)
);
} catch (error) {
console.error('Failed to persist traffic entries:', error.message);
}
}
/**
* Convert timestamp to Date for timeframe filtering
* @private
*/
_getTimeframeSince(timeframe) {
const now = new Date();
const hours = {
'1h': 1,
'24h': 24,
'7d': 24 * 7,
'30d': 24 * 30
};
const hoursBack = hours[timeframe] || 24;
return new Date(now.getTime() - hoursBack * 60 * 60 * 1000);
}
/**
* Group entries by specified field
* @private
*/
_groupEntries(entries, groupBy) {
const groups = {};
entries.forEach(entry => {
let key;
switch (groupBy) {
case 'domain':
key = entry.domain;
break;
case 'method':
key = entry.method;
break;
case 'status':
key = Math.floor(entry.statusCode / 100) * 100; // Group by status class
break;
case 'hour':
key = new Date(entry.timestamp).getHours();
break;
default:
key = 'unknown';
}
if (!groups[key]) {
groups[key] = {
count: 0,
totalResponseTime: 0,
statusCodes: {},
methods: {}
};
}
groups[key].count++;
groups[key].totalResponseTime += entry.responseTime;
groups[key].statusCodes[entry.statusCode] =
(groups[key].statusCodes[entry.statusCode] || 0) + 1;
groups[key].methods[entry.method] =
(groups[key].methods[entry.method] || 0) + 1;
});
// Calculate averages
Object.values(groups).forEach(group => {
group.avgResponseTime = group.totalResponseTime / group.count;
});
return groups;
}
/**
* Calculate average of array
* @private
*/
_calculateAverage(numbers) {
if (numbers.length === 0) return 0;
return numbers.reduce((sum, num) => sum + num, 0) / numbers.length;
}
/**
* Convert entry to HAR format
* @private
*/
_convertToHAREntry(entry) {
return {
startedDateTime: entry.timestamp,
time: entry.responseTime,
request: {
method: entry.method,
url: entry.url,
httpVersion: "HTTP/1.1",
headers: this._formatHeaders(entry.headers?.request || {}),
queryString: [],
postData: entry.bodyCaptured ? {} : undefined,
headersSize: -1,
bodySize: -1
},
response: {
status: entry.statusCode,
statusText: this._getStatusText(entry.statusCode),
httpVersion: "HTTP/1.1",
headers: this._formatHeaders(entry.headers?.response || {}),
content: {
size: -1,
mimeType: entry.headers?.response?.['content-type'] || "text/html"
},
headersSize: -1,
bodySize: -1
},
cache: {},
timings: {
send: 0,
wait: entry.responseTime,
receive: 0
}
};
}
/**
* Format headers for HAR
* @private
*/
_formatHeaders(headers) {
return Object.entries(headers).map(([name, value]) => ({
name,
value: String(value)
}));
}
/**
* Get HTTP status text
* @private
*/
_getStatusText(statusCode) {
const statusTexts = {
200: 'OK',
201: 'Created',
204: 'No Content',
301: 'Moved Permanently',
302: 'Found',
304: 'Not Modified',
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable'
};
return statusTexts[statusCode] || 'Unknown';
}
/**
* Get recent activity summary
* @private
*/
_getRecentActivity() {
const recentEntries = this.getEntries({ limit: 10 });
return {
lastRequestTime: recentEntries.length > 0 ?
recentEntries[0].timestamp : null,
recentDomains: [...new Set(recentEntries.map(e => e.domain))],
requestsLast10: recentEntries.length
};
}
}