@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
729 lines (626 loc) • 17.8 kB
text/typescript
/**
* Performance monitoring and optimization utilities for ProductBoard MCP server
*/
/* eslint-disable no-undef */
export interface PerformanceMetrics {
requestId: string;
operation: string;
startTime: number;
endTime?: number;
duration?: number;
cacheHit?: boolean;
dataSize?: number;
memoryUsage?: NodeJS.MemoryUsage;
errorCount?: number;
retryCount?: number;
}
export interface CacheEntry<T = unknown> {
data: T;
timestamp: number;
ttl: number;
accessCount: number;
lastAccessed: number;
size?: number;
}
export interface CacheStats {
totalEntries: number;
hitRate: number;
memoryUsage: number;
oldestEntry: number;
newestEntry: number;
averageAccessCount: number;
}
export interface ThrottleConfig {
maxRequests: number;
windowMs: number;
skipSuccessfulRequests?: boolean;
skipFailedRequests?: boolean;
keyGenerator?: (operation: string, params?: unknown) => string;
}
export interface QueryOptimization {
batchSize: number;
concurrency: number;
retryConfig: {
maxRetries: number;
backoffMs: number;
exponential: boolean;
};
cacheConfig: {
ttl: number;
maxSize: number;
strategy: 'LRU' | 'TTL' | 'FIFO';
};
}
/**
* Advanced caching system with intelligent TTL and memory management
*/
export class IntelligentCache<T = unknown> {
private cache = new Map<string, CacheEntry<T>>();
private accessOrder: string[] = [];
private maxSize: number;
private defaultTtl: number;
private strategy: 'LRU' | 'TTL' | 'FIFO';
private hitCount = 0;
private missCount = 0;
constructor(
maxSize: number = 1000,
defaultTtl: number = 300000, // 5 minutes
strategy: 'LRU' | 'TTL' | 'FIFO' = 'LRU'
) {
this.maxSize = maxSize;
this.defaultTtl = defaultTtl;
this.strategy = strategy;
}
/**
* Get cached value with intelligent expiration
*/
get(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
this.missCount++;
return null;
}
const now = Date.now();
// Check if entry is expired
if (now - entry.timestamp > entry.ttl) {
this.cache.delete(key);
this.removeFromAccessOrder(key);
this.missCount++;
return null;
}
// Update access statistics
entry.accessCount++;
entry.lastAccessed = now;
this.hitCount++;
// Update LRU order
if (this.strategy === 'LRU') {
this.updateAccessOrder(key);
}
return entry.data;
}
/**
* Set cached value with dynamic TTL based on data characteristics
*/
set(key: string, data: T, customTtl?: number): void {
const now = Date.now();
const ttl = customTtl || this.calculateDynamicTtl(data);
const size = this.estimateDataSize(data);
const entry: CacheEntry<T> = {
data,
timestamp: now,
ttl,
accessCount: 0,
lastAccessed: now,
size,
};
// Remove old entry if exists
if (this.cache.has(key)) {
this.removeFromAccessOrder(key);
}
// Evict if at capacity
while (this.cache.size >= this.maxSize) {
this.evictEntry();
}
this.cache.set(key, entry);
this.accessOrder.push(key);
}
/**
* Calculate dynamic TTL based on data characteristics
*/
private calculateDynamicTtl(data: T): number {
// Base TTL
let ttl = this.defaultTtl;
// Adjust TTL based on data type and size
if (Array.isArray(data)) {
// Larger arrays get longer TTL (more expensive to regenerate)
const arrayLength = data.length;
if (arrayLength > 100) ttl *= 2;
if (arrayLength > 1000) ttl *= 3;
}
// Static data gets longer TTL
if (typeof data === 'object' && data !== null) {
const obj = data as Record<string, unknown>;
if (
obj.status &&
['released', 'archived'].includes(obj.status as string)
) {
ttl *= 5; // Static status data can be cached longer
}
if (obj.createdAt && !obj.updatedAt) {
ttl *= 3; // Immutable data gets longer TTL
}
}
return Math.min(ttl, 3600000); // Max 1 hour TTL
}
/**
* Estimate data size for memory management
*/
private estimateDataSize(data: T): number {
try {
return JSON.stringify(data).length * 2; // Rough estimate
} catch {
return 1000; // Default size estimate
}
}
/**
* Evict entry based on strategy
*/
private evictEntry(): void {
let keyToEvict: string | undefined;
switch (this.strategy) {
case 'LRU':
keyToEvict = this.accessOrder[0];
break;
case 'FIFO':
keyToEvict = this.accessOrder[0];
break;
case 'TTL': {
// Find entry with shortest remaining TTL
let shortestTtl = Infinity;
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
const remainingTtl = entry.ttl - (now - entry.timestamp);
if (remainingTtl < shortestTtl) {
shortestTtl = remainingTtl;
keyToEvict = key;
}
}
break;
}
}
if (keyToEvict) {
this.cache.delete(keyToEvict);
this.removeFromAccessOrder(keyToEvict);
}
}
/**
* Update access order for LRU strategy
*/
private updateAccessOrder(key: string): void {
this.removeFromAccessOrder(key);
this.accessOrder.push(key);
}
/**
* Remove key from access order array
*/
private removeFromAccessOrder(key: string): void {
const index = this.accessOrder.indexOf(key);
if (index > -1) {
this.accessOrder.splice(index, 1);
}
}
/**
* Get cache statistics
*/
getStats(): CacheStats {
const now = Date.now();
let totalSize = 0;
let oldestEntry = now;
let newestEntry = 0;
let totalAccessCount = 0;
for (const entry of this.cache.values()) {
totalSize += entry.size || 0;
if (entry.timestamp < oldestEntry) oldestEntry = entry.timestamp;
if (entry.timestamp > newestEntry) newestEntry = entry.timestamp;
totalAccessCount += entry.accessCount;
}
return {
totalEntries: this.cache.size,
hitRate: this.hitCount / (this.hitCount + this.missCount) || 0,
memoryUsage: totalSize,
oldestEntry,
newestEntry,
averageAccessCount: totalAccessCount / this.cache.size || 0,
};
}
/**
* Clear expired entries
*/
clearExpired(): number {
const now = Date.now();
let clearedCount = 0;
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > entry.ttl) {
this.cache.delete(key);
this.removeFromAccessOrder(key);
clearedCount++;
}
}
return clearedCount;
}
/**
* Clear all cached entries
*/
clear(): void {
this.cache.clear();
this.accessOrder = [];
this.hitCount = 0;
this.missCount = 0;
}
/**
* Get cache size
*/
size(): number {
return this.cache.size;
}
}
/**
* Request throttling and rate limiting
*/
export class RequestThrottler {
private requests = new Map<string, number[]>();
private config: ThrottleConfig;
constructor(config: ThrottleConfig) {
this.config = config;
}
/**
* Check if request should be throttled
*/
shouldThrottle(operation: string, params?: unknown): boolean {
const key = this.config.keyGenerator
? this.config.keyGenerator(operation, params)
: operation;
const now = Date.now();
const windowStart = now - this.config.windowMs;
// Get or create request history for this key
let requestTimes = this.requests.get(key) || [];
// Remove requests outside the current window
requestTimes = requestTimes.filter(time => time > windowStart);
// Update the map
this.requests.set(key, requestTimes);
// Check if we're at the limit
return requestTimes.length >= this.config.maxRequests;
}
/**
* Record a request
*/
recordRequest(
operation: string,
params?: unknown,
wasSuccessful?: boolean
): void {
// Skip recording based on config
if (wasSuccessful && this.config.skipSuccessfulRequests) return;
if (!wasSuccessful && this.config.skipFailedRequests) return;
const key = this.config.keyGenerator
? this.config.keyGenerator(operation, params)
: operation;
const now = Date.now();
const requestTimes = this.requests.get(key) || [];
requestTimes.push(now);
this.requests.set(key, requestTimes);
}
/**
* Get throttle status for operation
*/
getThrottleStatus(
operation: string,
params?: unknown
): {
isThrottled: boolean;
requestCount: number;
timeUntilReset: number;
} {
const key = this.config.keyGenerator
? this.config.keyGenerator(operation, params)
: operation;
const requestTimes = this.requests.get(key) || [];
const now = Date.now();
const activeRequests = requestTimes.filter(
time => time > now - this.config.windowMs
);
return {
isThrottled: activeRequests.length >= this.config.maxRequests,
requestCount: activeRequests.length,
timeUntilReset:
activeRequests.length > 0
? this.config.windowMs - (now - Math.min(...activeRequests))
: 0,
};
}
}
/**
* Performance metrics collector
*/
export class PerformanceCollector {
private metrics: PerformanceMetrics[] = [];
private maxMetrics: number;
constructor(maxMetrics: number = 10000) {
this.maxMetrics = maxMetrics;
}
/**
* Start tracking a performance metric
*/
start(operation: string, requestId?: string): PerformanceMetrics {
const metric: PerformanceMetrics = {
requestId: requestId || this.generateRequestId(),
operation,
startTime: Date.now(),
memoryUsage: process.memoryUsage(),
};
this.metrics.push(metric);
// Keep metrics array from growing too large
if (this.metrics.length > this.maxMetrics) {
this.metrics.shift();
}
return metric;
}
/**
* End tracking a performance metric
*/
end(metric: PerformanceMetrics, success: boolean = true): PerformanceMetrics {
metric.endTime = Date.now();
metric.duration = metric.endTime - metric.startTime;
if (!success) {
metric.errorCount = (metric.errorCount || 0) + 1;
}
return metric;
}
/**
* Record cache hit/miss
*/
recordCacheHit(metric: PerformanceMetrics, isHit: boolean): void {
metric.cacheHit = isHit;
}
/**
* Record data size
*/
recordDataSize(metric: PerformanceMetrics, dataSize: number): void {
metric.dataSize = dataSize;
}
/**
* Record retry attempt
*/
recordRetry(metric: PerformanceMetrics): void {
metric.retryCount = (metric.retryCount || 0) + 1;
}
/**
* Get performance statistics
*/
getStats(operation?: string): {
totalRequests: number;
averageDuration: number;
cacheHitRate: number;
errorRate: number;
averageDataSize: number;
percentiles: {
p50: number;
p90: number;
p95: number;
p99: number;
};
} {
const filteredMetrics = operation
? this.metrics.filter(m => m.operation === operation)
: this.metrics;
if (filteredMetrics.length === 0) {
return {
totalRequests: 0,
averageDuration: 0,
cacheHitRate: 0,
errorRate: 0,
averageDataSize: 0,
percentiles: { p50: 0, p90: 0, p95: 0, p99: 0 },
};
}
const durations = filteredMetrics
.filter(m => m.duration !== undefined)
.map(m => m.duration as number)
.sort((a, b) => a - b);
const cacheHits = filteredMetrics.filter(m => m.cacheHit === true).length;
const cacheTotal = filteredMetrics.filter(
m => m.cacheHit !== undefined
).length;
const errors = filteredMetrics.filter(m => (m.errorCount || 0) > 0).length;
const dataSizes = filteredMetrics
.filter(m => m.dataSize !== undefined)
.map(m => m.dataSize as number);
return {
totalRequests: filteredMetrics.length,
averageDuration:
durations.reduce((a, b) => a + b, 0) / durations.length || 0,
cacheHitRate: cacheTotal > 0 ? cacheHits / cacheTotal : 0,
errorRate: errors / filteredMetrics.length,
averageDataSize:
dataSizes.reduce((a, b) => a + b, 0) / dataSizes.length || 0,
percentiles: {
p50: this.percentile(durations, 0.5),
p90: this.percentile(durations, 0.9),
p95: this.percentile(durations, 0.95),
p99: this.percentile(durations, 0.99),
},
};
}
/**
* Calculate percentile
*/
private percentile(sortedArray: number[], percentile: number): number {
if (sortedArray.length === 0) return 0;
const index = Math.ceil(sortedArray.length * percentile) - 1;
return sortedArray[Math.max(0, index)];
}
/**
* Generate unique request ID
*/
private generateRequestId(): string {
return `perf_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Clear old metrics
*/
clearOldMetrics(olderThanMs: number = 3600000): number {
const cutoff = Date.now() - olderThanMs;
const initialLength = this.metrics.length;
this.metrics = this.metrics.filter(m => m.startTime > cutoff);
return initialLength - this.metrics.length;
}
/**
* Get current metrics count
*/
getMetricsCount(): number {
return this.metrics.length;
}
}
/**
* Memory usage monitor
*/
export class MemoryMonitor {
private measurements: Array<{
timestamp: number;
usage: NodeJS.MemoryUsage;
}> = [];
private maxMeasurements: number;
constructor(maxMeasurements: number = 1000) {
this.maxMeasurements = maxMeasurements;
}
/**
* Record current memory usage
*/
recordUsage(): NodeJS.MemoryUsage {
const usage = process.memoryUsage();
const measurement = {
timestamp: Date.now(),
usage,
};
this.measurements.push(measurement);
// Keep measurements array from growing too large
if (this.measurements.length > this.maxMeasurements) {
this.measurements.shift();
}
return usage;
}
/**
* Get memory usage statistics
*/
getStats(): {
current: NodeJS.MemoryUsage;
peak: {
rss: number;
heapTotal: number;
heapUsed: number;
external: number;
};
average: {
rss: number;
heapTotal: number;
heapUsed: number;
external: number;
};
trend: 'increasing' | 'decreasing' | 'stable';
} {
if (this.measurements.length === 0) {
const current = process.memoryUsage();
return {
current,
peak: current,
average: current,
trend: 'stable',
};
}
const current = this.measurements[this.measurements.length - 1].usage;
// Calculate peak values
const peak = this.measurements.reduce(
(max, measurement) => ({
rss: Math.max(max.rss, measurement.usage.rss),
heapTotal: Math.max(max.heapTotal, measurement.usage.heapTotal),
heapUsed: Math.max(max.heapUsed, measurement.usage.heapUsed),
external: Math.max(max.external, measurement.usage.external),
}),
{ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }
);
// Calculate average values
const total = this.measurements.reduce(
(sum, measurement) => ({
rss: sum.rss + measurement.usage.rss,
heapTotal: sum.heapTotal + measurement.usage.heapTotal,
heapUsed: sum.heapUsed + measurement.usage.heapUsed,
external: sum.external + measurement.usage.external,
}),
{ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }
);
const average = {
rss: total.rss / this.measurements.length,
heapTotal: total.heapTotal / this.measurements.length,
heapUsed: total.heapUsed / this.measurements.length,
external: total.external / this.measurements.length,
};
// Calculate trend
const trend = this.calculateTrend();
return {
current,
peak,
average,
trend,
};
}
/**
* Calculate memory usage trend
*/
private calculateTrend(): 'increasing' | 'decreasing' | 'stable' {
if (this.measurements.length < 10) return 'stable';
const recent = this.measurements.slice(-10);
const older = this.measurements.slice(-20, -10);
if (older.length === 0) return 'stable';
const recentAvg =
recent.reduce((sum, m) => sum + m.usage.heapUsed, 0) / recent.length;
const olderAvg =
older.reduce((sum, m) => sum + m.usage.heapUsed, 0) / older.length;
const changePercent = (recentAvg - olderAvg) / olderAvg;
if (changePercent > 0.1) return 'increasing';
if (changePercent < -0.1) return 'decreasing';
return 'stable';
}
/**
* Check if memory usage is critical
*/
isCritical(thresholdMB: number = 1000): boolean {
const current = process.memoryUsage();
return current.heapUsed / 1024 / 1024 > thresholdMB;
}
/**
* Force garbage collection if available
*/
forceGC(): boolean {
if (global.gc) {
global.gc();
return true;
}
return false;
}
}
// Export singleton instances for easy use
export const performanceCollector = new PerformanceCollector();
export const memoryMonitor = new MemoryMonitor();
export const globalCache = new IntelligentCache(5000, 300000, 'LRU');
// Default throttle configurations
export const defaultThrottleConfig: ThrottleConfig = {
maxRequests: 100,
windowMs: 60000, // 1 minute
keyGenerator: (operation: string) => operation,
};
export const aggressiveThrottleConfig: ThrottleConfig = {
maxRequests: 20,
windowMs: 60000,
keyGenerator: (operation: string, params?: unknown) =>
`${operation}_${(params as Record<string, unknown>)?.instance || 'default'}`,
};