a cache for slack profile pictures and emojis
1import { Database } from "bun:sqlite"; 2import { schedule } from "node-cron"; 3import { MigrationManager } from "./migrations/migrationManager"; 4import { endpointGroupingMigration } from "./migrations/endpointGroupingMigration"; 5import { logGroupingMigration } from "./migrations/logGroupingMigration"; 6 7/** 8 * @fileoverview This file contains the Cache class for storing user and emoji data with automatic expiration. To use the module in your project, import the default export and create a new instance of the Cache class. The class provides methods for inserting and retrieving user and emoji data from the cache. The cache automatically purges expired items every hour. 9 * @module cache 10 * @requires bun:sqlite 11 * @requires node-cron 12 */ 13 14/** 15 * Base interface for cached items 16 */ 17interface CacheItem { 18 id: string; 19 imageUrl: string; 20 expiration: Date; 21} 22 23/** 24 * Interface for cached user data 25 */ 26interface User extends CacheItem { 27 type: "user"; 28 displayName: string; 29 pronouns: string; 30 userId: string; 31} 32 33/** 34 * Interface for cached emoji data 35 */ 36interface Emoji extends CacheItem { 37 type: "emoji"; 38 name: string; 39 alias: string | null; 40} 41 42/** 43 * Cache class for storing user and emoji data with automatic expiration 44 */ 45class Cache { 46 private db: Database; 47 private defaultExpiration: number; // in hours 48 private onEmojiExpired?: () => void; 49 private analyticsCache: Map<string, { data: any; timestamp: number }> = new Map(); 50 private analyticsCacheTTL = 30000; // 30 second cache for faster updates 51 52 // Background user update queue to avoid Slack API limits 53 private userUpdateQueue: Set<string> = new Set(); 54 private isProcessingQueue = false; 55 private slackWrapper?: any; // Will be injected after construction 56 57 /** 58 * Creates a new Cache instance 59 * @param dbPath Path to SQLite database file 60 * @param defaultExpirationHours Default cache expiration in hours 61 * @param onEmojiExpired Optional callback function called when emojis expire 62 */ 63 constructor( 64 dbPath: string, 65 defaultExpirationHours = 24, 66 onEmojiExpired?: () => void, 67 ) { 68 this.db = new Database(dbPath); 69 this.defaultExpiration = defaultExpirationHours; 70 this.onEmojiExpired = onEmojiExpired; 71 72 this.initDatabase(); 73 this.setupPurgeSchedule(); 74 this.startQueueProcessor(); 75 76 // Run migrations 77 this.runMigrations(); 78 } 79 80 /** 81 * Initializes the database tables 82 * @private 83 */ 84 private initDatabase() { 85 // Create users table 86 this.db.run(` 87 CREATE TABLE IF NOT EXISTS users ( 88 id TEXT PRIMARY KEY, 89 userId TEXT UNIQUE, 90 displayName TEXT, 91 pronouns TEXT, 92 imageUrl TEXT, 93 expiration INTEGER 94 ) 95 `); 96 97 // Create emojis table 98 this.db.run(` 99 CREATE TABLE IF NOT EXISTS emojis ( 100 id TEXT PRIMARY KEY, 101 name TEXT UNIQUE, 102 alias TEXT, 103 imageUrl TEXT, 104 expiration INTEGER 105 ) 106 `); 107 108 // Create request analytics table 109 this.db.run(` 110 CREATE TABLE IF NOT EXISTS request_analytics ( 111 id TEXT PRIMARY KEY, 112 endpoint TEXT NOT NULL, 113 method TEXT NOT NULL, 114 status_code INTEGER NOT NULL, 115 user_agent TEXT, 116 ip_address TEXT, 117 timestamp INTEGER NOT NULL, 118 response_time INTEGER 119 ) 120 `); 121 122 // Create index for faster queries 123 this.db.run(` 124 CREATE INDEX IF NOT EXISTS idx_request_analytics_timestamp 125 ON request_analytics(timestamp) 126 `); 127 128 this.db.run(` 129 CREATE INDEX IF NOT EXISTS idx_request_analytics_endpoint 130 ON request_analytics(endpoint) 131 `); 132 133 this.db.run(` 134 CREATE INDEX IF NOT EXISTS idx_request_analytics_status_timestamp 135 ON request_analytics(status_code, timestamp) 136 `); 137 138 this.db.run(` 139 CREATE INDEX IF NOT EXISTS idx_request_analytics_response_time 140 ON request_analytics(response_time) WHERE response_time IS NOT NULL 141 `); 142 143 this.db.run(` 144 CREATE INDEX IF NOT EXISTS idx_request_analytics_composite 145 ON request_analytics(timestamp, endpoint, status_code) 146 `); 147 148 // Additional performance indexes 149 this.db.run(` 150 CREATE INDEX IF NOT EXISTS idx_request_analytics_user_agent 151 ON request_analytics(user_agent, timestamp) WHERE user_agent IS NOT NULL 152 `); 153 154 this.db.run(` 155 CREATE INDEX IF NOT EXISTS idx_request_analytics_time_response 156 ON request_analytics(timestamp, response_time) WHERE response_time IS NOT NULL 157 `); 158 159 this.db.run(` 160 CREATE INDEX IF NOT EXISTS idx_request_analytics_exclude_stats 161 ON request_analytics(timestamp, endpoint, status_code) WHERE endpoint != '/stats' 162 `); 163 164 // Enable WAL mode for better concurrent performance 165 this.db.run('PRAGMA journal_mode = WAL'); 166 this.db.run('PRAGMA synchronous = NORMAL'); 167 this.db.run('PRAGMA cache_size = 50000'); // Increased cache size 168 this.db.run('PRAGMA temp_store = memory'); 169 this.db.run('PRAGMA mmap_size = 268435456'); // 256MB memory map 170 this.db.run('PRAGMA page_size = 4096'); // Optimal page size 171 172 // check if there are any emojis in the db 173 if (this.onEmojiExpired) { 174 const result = this.db 175 .query("SELECT COUNT(*) as count FROM emojis WHERE expiration > ?") 176 .get(Date.now()) as { count: number }; 177 if (result.count === 0) { 178 this.onEmojiExpired(); 179 } 180 } 181 } 182 183 /** 184 * Sets up scheduled tasks for cache maintenance 185 * @private 186 */ 187 private setupPurgeSchedule() { 188 // Run purge every hour at 45 minutes (only expired items, analytics cleanup) 189 schedule("45 * * * *", async () => { 190 await this.purgeExpiredItems(); 191 await this.lazyUserCleanup(); 192 }); 193 194 // Schedule emoji updates daily at 3 AM to avoid peak hours 195 schedule("0 3 * * *", async () => { 196 console.log("Scheduled emoji update starting..."); 197 if (this.onEmojiExpired) { 198 await this.onEmojiExpired(); 199 console.log("Scheduled emoji update completed"); 200 } 201 }); 202 } 203 204 /** 205 * Run database migrations 206 * @private 207 */ 208 private async runMigrations() { 209 try { 210 // Define migrations directly here to avoid circular dependencies 211 // Note: We define migrations both here and in migrations/index.ts 212 // This is intentional to prevent circular imports 213 const migrations = [ 214 endpointGroupingMigration, 215 logGroupingMigration 216 ]; 217 const migrationManager = new MigrationManager(this.db, migrations); 218 const result = await migrationManager.runMigrations(); 219 220 if (result.migrationsApplied > 0) { 221 console.log(`Applied ${result.migrationsApplied} migrations. Latest version: ${result.lastAppliedVersion}`); 222 } else { 223 console.log("No new migrations to apply"); 224 } 225 } catch (error) { 226 console.error("Error running migrations:", error); 227 } 228 } 229 230 /** 231 * Purges expired items from the cache 232 * @returns int indicating number of items purged 233 */ 234 async purgeExpiredItems(): Promise<number> { 235 // Only purge emojis - users will use lazy loading with longer TTL 236 const result2 = this.db.run("DELETE FROM emojis WHERE expiration < ?", [ 237 Date.now(), 238 ]); 239 240 // Clean up old analytics data (older than 30 days) - moved to off-peak hours 241 const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; 242 const currentHour = new Date().getHours(); 243 // Only run analytics cleanup during off-peak hours (2-6 AM) 244 if (currentHour >= 2 && currentHour < 6) { 245 this.db.run("DELETE FROM request_analytics WHERE timestamp < ?", [ 246 thirtyDaysAgo, 247 ]); 248 console.log(`Analytics cleanup completed - removed records older than 30 days`); 249 } 250 251 // Emojis are now updated on schedule, not on expiration 252 return result2.changes; 253 } 254 255 /** 256 * Lazy cleanup of truly expired users (older than 7 days) during off-peak hours only 257 * This runs much less frequently than the old aggressive purging 258 * @private 259 */ 260 private async lazyUserCleanup(): Promise<void> { 261 const currentHour = new Date().getHours(); 262 // Only run during off-peak hours (3-5 AM) and not every time 263 if (currentHour >= 3 && currentHour < 5 && Math.random() < 0.1) { // 10% chance 264 const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; 265 const result = this.db.run("DELETE FROM users WHERE expiration < ?", [ 266 sevenDaysAgo, 267 ]); 268 if (result.changes > 0) { 269 console.log(`Lazy user cleanup: removed ${result.changes} expired users`); 270 } 271 } 272 } 273 274 /** 275 * Purges cache for a specific user 276 * @param userId The Slack user ID to purge from cache 277 * @returns boolean indicating if any user was purged 278 */ 279 async purgeUserCache(userId: string): Promise<boolean> { 280 try { 281 const result = this.db.run("DELETE FROM users WHERE userId = ?", [ 282 userId.toUpperCase(), 283 ]); 284 return result.changes > 0; 285 } catch (error) { 286 console.error("Error purging user cache:", error); 287 return false; 288 } 289 } 290 291 /** 292 * Purges all items from the cache 293 * @returns Object containing purge results 294 */ 295 async purgeAll(): Promise<{ 296 message: string; 297 users: number; 298 emojis: number; 299 }> { 300 const result = this.db.run("DELETE FROM users"); 301 const result2 = this.db.run("DELETE FROM emojis"); 302 303 if (this.onEmojiExpired) { 304 if (result2.changes > 0) { 305 this.onEmojiExpired(); 306 } 307 } 308 309 return { 310 message: "Cache purged", 311 users: result.changes, 312 emojis: result2.changes, 313 }; 314 } 315 316 /** 317 * Checks if the cache is healthy by testing database connectivity 318 * @returns boolean indicating if cache is healthy 319 */ 320 async healthCheck(): Promise<boolean> { 321 try { 322 this.db.query("SELECT 1").get(); 323 return true; 324 } catch (error) { 325 console.error("Cache health check failed:", error); 326 return false; 327 } 328 } 329 330 /** 331 * Sets the Slack wrapper for user updates 332 * @param slackWrapper SlackWrapper instance for API calls 333 */ 334 setSlackWrapper(slackWrapper: any) { 335 this.slackWrapper = slackWrapper; 336 } 337 338 /** 339 * Adds a user to the background update queue 340 * @param userId User ID to queue for update 341 * @private 342 */ 343 private queueUserUpdate(userId: string) { 344 this.userUpdateQueue.add(userId.toUpperCase()); 345 } 346 347 /** 348 * Starts the background queue processor 349 * @private 350 */ 351 private startQueueProcessor() { 352 // Process queue every 30 seconds to respect Slack API limits 353 setInterval(async () => { 354 await this.processUserUpdateQueue(); 355 }, 30 * 1000); 356 } 357 358 /** 359 * Processes the user update queue with rate limiting 360 * @private 361 */ 362 private async processUserUpdateQueue() { 363 if (this.isProcessingQueue || this.userUpdateQueue.size === 0 || !this.slackWrapper) { 364 return; 365 } 366 367 this.isProcessingQueue = true; 368 369 try { 370 // Process up to 3 users at a time to respect API limits 371 const usersToUpdate = Array.from(this.userUpdateQueue).slice(0, 3); 372 373 for (const userId of usersToUpdate) { 374 try { 375 console.log(`Background updating user: ${userId}`); 376 const slackUser = await this.slackWrapper.getUserInfo(userId); 377 378 // Update user in cache with fresh data 379 await this.insertUser( 380 slackUser.id, 381 slackUser.real_name || slackUser.name || "Unknown", 382 slackUser.profile?.pronouns || "", 383 slackUser.profile?.image_512 || slackUser.profile?.image_192 || "" 384 ); 385 386 // Remove from queue after successful update 387 this.userUpdateQueue.delete(userId); 388 } catch (error) { 389 console.warn(`Failed to update user ${userId}:`, error); 390 // Remove from queue even if failed to prevent infinite retry 391 this.userUpdateQueue.delete(userId); 392 } 393 } 394 } catch (error) { 395 console.error("Error processing user update queue:", error); 396 } finally { 397 this.isProcessingQueue = false; 398 } 399 } 400 401 /** 402 * Inserts a user into the cache 403 * @param userId Unique identifier for the user 404 * @param imageUrl URL of the user's image 405 * @param expirationHours Optional custom expiration time in hours 406 * @returns boolean indicating success 407 */ 408 async insertUser( 409 userId: string, 410 displayName: string, 411 pronouns: string, 412 imageUrl: string, 413 expirationHours?: number, 414 ) { 415 const id = crypto.randomUUID(); 416 // Users get longer TTL (7 days) for lazy loading, unless custom expiration specified 417 const userDefaultTTL = 7 * 24; // 7 days in hours 418 const expiration = 419 Date.now() + (expirationHours || userDefaultTTL) * 3600000; 420 421 try { 422 this.db.run( 423 `INSERT INTO users (id, userId, displayName, pronouns, imageUrl, expiration) 424 VALUES (?, ?, ?, ?, ?, ?) 425 ON CONFLICT(userId) 426 DO UPDATE SET imageUrl = ?, expiration = ?`, 427 [ 428 id, 429 userId.toUpperCase(), 430 displayName, 431 pronouns, 432 imageUrl, 433 expiration, 434 imageUrl, 435 expiration, 436 ], 437 ); 438 return true; 439 } catch (error) { 440 console.error("Error inserting/updating user:", error); 441 return false; 442 } 443 } 444 445 /** 446 * Inserts an emoji into the cache 447 * @param name Name of the emoji 448 * @param imageUrl URL of the emoji image 449 * @param expirationHours Optional custom expiration time in hours 450 * @returns boolean indicating success 451 */ 452 async insertEmoji( 453 name: string, 454 alias: string | null, 455 imageUrl: string, 456 expirationHours?: number, 457 ) { 458 const id = crypto.randomUUID(); 459 const expiration = 460 Date.now() + (expirationHours || this.defaultExpiration) * 3600000; 461 462 try { 463 this.db.run( 464 `INSERT INTO emojis (id, name, alias, imageUrl, expiration) 465 VALUES (?, ?, ?, ?, ?) 466 ON CONFLICT(name) 467 DO UPDATE SET imageUrl = ?, expiration = ?`, 468 [ 469 id, 470 name.toLowerCase(), 471 alias?.toLowerCase() || null, 472 imageUrl, 473 expiration, 474 imageUrl, 475 expiration, 476 ], 477 ); 478 return true; 479 } catch (error) { 480 console.error("Error inserting/updating emoji:", error); 481 return false; 482 } 483 } 484 485 /** 486 * Batch inserts multiple emojis into the cache 487 * @param emojis Array of {name, imageUrl} objects to insert 488 * @param expirationHours Optional custom expiration time in hours for all emojis 489 * @returns boolean indicating if all insertions were successful 490 */ 491 async batchInsertEmojis( 492 emojis: Array<{ name: string; imageUrl: string; alias: string | null }>, 493 expirationHours?: number, 494 ): Promise<boolean> { 495 try { 496 const expiration = 497 Date.now() + (expirationHours || this.defaultExpiration) * 3600000; 498 499 this.db.transaction(() => { 500 for (const emoji of emojis) { 501 const id = crypto.randomUUID(); 502 this.db.run( 503 `INSERT INTO emojis (id, name, alias, imageUrl, expiration) 504 VALUES (?, ?, ?, ?, ?) 505 ON CONFLICT(name) 506 DO UPDATE SET imageUrl = ?, expiration = ?`, 507 [ 508 id, 509 emoji.name.toLowerCase(), 510 emoji.alias?.toLowerCase() || null, 511 emoji.imageUrl, 512 expiration, 513 emoji.imageUrl, 514 expiration, 515 ], 516 ); 517 } 518 })(); 519 520 return true; 521 } catch (error) { 522 console.error("Error batch inserting emojis:", error); 523 return false; 524 } 525 } 526 527 /** 528 * Lists all emoji in the cache 529 * @returns Array of Emoji objects that haven't expired 530 */ 531 async listEmojis(): Promise<Emoji[]> { 532 const results = this.db 533 .query("SELECT * FROM emojis WHERE expiration > ?") 534 .all(Date.now()) as Emoji[]; 535 536 return results.map((result) => ({ 537 type: "emoji", 538 id: result.id, 539 name: result.name, 540 alias: result.alias || null, 541 imageUrl: result.imageUrl, 542 expiration: new Date(result.expiration), 543 })); 544 } 545 546 /** 547 * Retrieves a user from the cache 548 * @param userId Unique identifier of the user 549 * @returns User object if found and not expired, null otherwise 550 */ 551 async getUser(userId: string): Promise<User | null> { 552 const result = this.db 553 .query("SELECT * FROM users WHERE userId = ?") 554 .get(userId.toUpperCase()) as User; 555 556 if (!result) { 557 return null; 558 } 559 560 const now = Date.now(); 561 const expiration = new Date(result.expiration).getTime(); 562 563 // If user is expired, remove and return null 564 if (expiration < now) { 565 this.db.run("DELETE FROM users WHERE userId = ?", [userId]); 566 return null; 567 } 568 569 // Touch-to-refresh: if user is older than 24 hours, extend TTL and queue for background update 570 const twentyFourHoursAgo = now - (24 * 60 * 60 * 1000); 571 const userAge = expiration - (7 * 24 * 60 * 60 * 1000); // When user was originally cached 572 573 if (userAge < twentyFourHoursAgo) { 574 // Extend TTL by another 7 days from now 575 const newExpiration = now + (7 * 24 * 60 * 60 * 1000); 576 this.db.run("UPDATE users SET expiration = ? WHERE userId = ?", [ 577 newExpiration, 578 userId.toUpperCase() 579 ]); 580 581 // Queue for background update to get fresh data 582 this.queueUserUpdate(userId); 583 584 console.log(`Touch-refresh: Extended TTL for user ${userId} and queued for update`); 585 } 586 587 return { 588 type: "user", 589 id: result.id, 590 userId: result.userId, 591 displayName: result.displayName, 592 pronouns: result.pronouns, 593 imageUrl: result.imageUrl, 594 expiration: new Date(result.expiration), 595 }; 596 } 597 598 /** 599 * Retrieves an emoji from the cache 600 * @param name Name of the emoji 601 * @returns Emoji object if found and not expired, null otherwise 602 */ 603 async getEmoji(name: string): Promise<Emoji | null> { 604 const result = this.db 605 .query("SELECT * FROM emojis WHERE name = ? AND expiration > ?") 606 .get(name.toLowerCase(), Date.now()) as Emoji; 607 608 return result 609 ? { 610 type: "emoji", 611 id: result.id, 612 name: result.name, 613 alias: result.alias || null, 614 imageUrl: result.imageUrl, 615 expiration: new Date(result.expiration), 616 } 617 : null; 618 } 619 620 /** 621 * Get all emojis from the cache 622 * @returns Array of all non-expired emojis 623 */ 624 async getAllEmojis(): Promise<Emoji[]> { 625 const results = this.db 626 .query("SELECT * FROM emojis WHERE expiration > ?") 627 .all(Date.now()) as Emoji[]; 628 629 return results.map(result => ({ 630 type: "emoji", 631 id: result.id, 632 name: result.name, 633 alias: result.alias || null, 634 imageUrl: result.imageUrl, 635 expiration: new Date(result.expiration), 636 })); 637 } 638 639 /** 640 * Records a request for analytics 641 * @param endpoint The endpoint that was accessed 642 * @param method HTTP method 643 * @param statusCode HTTP status code 644 * @param userAgent User agent string 645 * @param ipAddress IP address of the client 646 * @param responseTime Response time in milliseconds 647 */ 648 async recordRequest( 649 endpoint: string, 650 method: string, 651 statusCode: number, 652 userAgent?: string, 653 ipAddress?: string, 654 responseTime?: number, 655 ): Promise<void> { 656 try { 657 const id = crypto.randomUUID(); 658 this.db.run( 659 `INSERT INTO request_analytics 660 (id, endpoint, method, status_code, user_agent, ip_address, timestamp, response_time) 661 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 662 [ 663 id, 664 endpoint, 665 method, 666 statusCode, 667 userAgent || null, 668 ipAddress || null, 669 Date.now(), 670 responseTime || null, 671 ], 672 ); 673 } catch (error) { 674 console.error("Error recording request analytics:", error); 675 } 676 } 677 678 /** 679 * Gets request analytics statistics with performance optimizations 680 * @param days Number of days to look back (default: 7) 681 * @returns Analytics data 682 */ 683 async getAnalytics(days: number = 7): Promise<{ 684 totalRequests: number; 685 requestsByEndpoint: Array<{ 686 endpoint: string; 687 count: number; 688 averageResponseTime: number; 689 }>; 690 requestsByStatus: Array<{ 691 status: number; 692 count: number; 693 averageResponseTime: number; 694 }>; 695 requestsByDay: Array<{ 696 date: string; 697 count: number; 698 averageResponseTime: number; 699 }>; 700 averageResponseTime: number | null; 701 topUserAgents: Array<{ userAgent: string; count: number }>; 702 latencyAnalytics: { 703 percentiles: { 704 p50: number | null; 705 p75: number | null; 706 p90: number | null; 707 p95: number | null; 708 p99: number | null; 709 }; 710 distribution: Array<{ 711 range: string; 712 count: number; 713 percentage: number; 714 }>; 715 slowestEndpoints: Array<{ 716 endpoint: string; 717 averageResponseTime: number; 718 count: number; 719 }>; 720 latencyOverTime: Array<{ 721 time: string; 722 averageResponseTime: number; 723 p95: number | null; 724 count: number; 725 }>; 726 }; 727 performanceMetrics: { 728 uptime: number; 729 errorRate: number; 730 throughput: number; 731 apdex: number; 732 cachehitRate: number; 733 }; 734 peakTraffic: { 735 peakHour: string; 736 peakRequests: number; 737 peakDay: string; 738 peakDayRequests: number; 739 }; 740 dashboardMetrics: { 741 statsRequests: number; 742 totalWithStats: number; 743 }; 744 trafficOverview: Array<{ 745 time: string; 746 routes: Record<string, number>; 747 total: number; 748 }>; 749 }> { 750 // Check cache first 751 const cacheKey = `analytics_${days}`; 752 const cached = this.analyticsCache.get(cacheKey); 753 const now = Date.now(); 754 755 if (cached && (now - cached.timestamp) < this.analyticsCacheTTL) { 756 return cached.data; 757 } 758 const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000; 759 760 // Total requests (excluding stats endpoint) 761 const totalResult = this.db 762 .query( 763 "SELECT COUNT(*) as count FROM request_analytics WHERE timestamp > ? AND endpoint != '/stats'", 764 ) 765 .get(cutoffTime) as { count: number }; 766 767 // Stats endpoint requests (tracked separately) 768 const statsResult = this.db 769 .query( 770 "SELECT COUNT(*) as count FROM request_analytics WHERE timestamp > ? AND endpoint = '/stats'", 771 ) 772 .get(cutoffTime) as { count: number }; 773 774 // Get raw endpoint data and group them intelligently (excluding stats) 775 const rawEndpointResults = this.db 776 .query( 777 ` 778 SELECT endpoint, COUNT(*) as count, AVG(response_time) as averageResponseTime 779 FROM request_analytics 780 WHERE timestamp > ? AND endpoint != '/stats' 781 GROUP BY endpoint 782 ORDER BY count DESC 783 `, 784 ) 785 .all(cutoffTime) as Array<{ 786 endpoint: string; 787 count: number; 788 averageResponseTime: number | null; 789 }>; 790 791 // Group endpoints intelligently 792 const endpointGroups: Record< 793 string, 794 { count: number; totalResponseTime: number; requestCount: number } 795 > = {}; 796 797 for (const result of rawEndpointResults) { 798 const endpoint = result.endpoint; 799 let groupKey: string; 800 801 if (endpoint === "/" || endpoint === "/dashboard") { 802 groupKey = "Dashboard"; 803 } else if (endpoint === "/health") { 804 groupKey = "Health Check"; 805 } else if (endpoint === "/swagger" || endpoint.startsWith("/swagger")) { 806 groupKey = "API Documentation"; 807 } else if (endpoint === "/emojis") { 808 groupKey = "Emoji List"; 809 } else if (endpoint.match(/^\/emojis\/[^\/]+$/)) { 810 groupKey = "Emoji Data"; 811 } else if (endpoint.match(/^\/emojis\/[^\/]+\/r$/)) { 812 groupKey = "Emoji Redirects"; 813 } else if (endpoint.match(/^\/users\/[^\/]+$/)) { 814 groupKey = "User Data"; 815 } else if (endpoint.match(/^\/users\/[^\/]+\/r$/)) { 816 groupKey = "User Redirects"; 817 } else if (endpoint.match(/^\/users\/[^\/]+\/purge$/)) { 818 groupKey = "Cache Management"; 819 } else if (endpoint === "/reset") { 820 groupKey = "Cache Management"; 821 } else { 822 // For any other endpoints, try to categorize them 823 if (endpoint.includes("/users/") && endpoint.includes("/r")) { 824 groupKey = "User Redirects"; 825 } else if (endpoint.includes("/users/")) { 826 groupKey = "User Data"; 827 } else if (endpoint.includes("/emojis/") && endpoint.includes("/r")) { 828 groupKey = "Emoji Redirects"; 829 } else if (endpoint.includes("/emojis/")) { 830 groupKey = "Emoji Data"; 831 } else { 832 groupKey = "Other"; 833 } 834 } 835 836 if (!endpointGroups[groupKey]) { 837 endpointGroups[groupKey] = { 838 count: 0, 839 totalResponseTime: 0, 840 requestCount: 0, 841 }; 842 } 843 844 // Defensive: Only update if groupKey exists (should always exist due to initialization above) 845 const group = endpointGroups[groupKey]; 846 if (group) { 847 group.count += result.count; 848 if ( 849 result.averageResponseTime !== null && 850 result.averageResponseTime !== undefined 851 ) { 852 group.totalResponseTime += result.averageResponseTime * result.count; 853 group.requestCount += result.count; 854 } 855 } 856 } 857 858 // Convert back to array format with calculated averages 859 const requestsByEndpoint = Object.entries(endpointGroups) 860 .map(([endpoint, data]) => ({ 861 endpoint, 862 count: data.count, 863 averageResponseTime: 864 data.requestCount > 0 865 ? data.totalResponseTime / data.requestCount 866 : 0, 867 })) 868 .sort((a, b) => b.count - a.count); 869 870 // Requests by status code with average response time (excluding stats) 871 const statusResultsRaw = this.db 872 .query( 873 ` 874 SELECT status_code as status, COUNT(*) as count, AVG(response_time) as averageResponseTime 875 FROM request_analytics 876 WHERE timestamp > ? AND endpoint != '/stats' 877 GROUP BY status_code 878 ORDER BY count DESC 879 `, 880 ) 881 .all(cutoffTime) as Array<{ 882 status: number; 883 count: number; 884 averageResponseTime: number | null; 885 }>; 886 887 const statusResults = statusResultsRaw.map((s) => ({ 888 status: s.status, 889 count: s.count, 890 averageResponseTime: s.averageResponseTime ?? 0, 891 })); 892 893 // Requests over time - hourly for 1 day, daily for longer periods 894 let timeResults: Array<{ 895 date: string; 896 count: number; 897 averageResponseTime: number; 898 }>; 899 900 if (days === 1) { 901 // 15-minute intervals for last 24 hours (excluding stats) 902 const intervalResultsRaw = this.db 903 .query( 904 ` 905 SELECT 906 strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 907 CASE 908 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 909 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 910 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 911 ELSE '45' 912 END as date, 913 COUNT(*) as count, 914 AVG(response_time) as averageResponseTime 915 FROM request_analytics 916 WHERE timestamp > ? AND endpoint != '/stats' 917 GROUP BY strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 918 CASE 919 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 920 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 921 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 922 ELSE '45' 923 END 924 ORDER BY date ASC 925 `, 926 ) 927 .all(cutoffTime) as Array<{ 928 date: string; 929 count: number; 930 averageResponseTime: number | null; 931 }>; 932 933 timeResults = intervalResultsRaw.map((h) => ({ 934 date: h.date, 935 count: h.count, 936 averageResponseTime: h.averageResponseTime ?? 0, 937 })); 938 } else if (days <= 7) { 939 // Hourly data for 7 days (excluding stats) 940 const hourResultsRaw = this.db 941 .query( 942 ` 943 SELECT 944 strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) as date, 945 COUNT(*) as count, 946 AVG(response_time) as averageResponseTime 947 FROM request_analytics 948 WHERE timestamp > ? AND endpoint != '/stats' 949 GROUP BY strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) 950 ORDER BY date ASC 951 `, 952 ) 953 .all(cutoffTime) as Array<{ 954 date: string; 955 count: number; 956 averageResponseTime: number | null; 957 }>; 958 959 timeResults = hourResultsRaw.map((h) => ({ 960 date: h.date, 961 count: h.count, 962 averageResponseTime: h.averageResponseTime ?? 0, 963 })); 964 } else { 965 // 4-hour intervals for longer periods (excluding stats) 966 const intervalResultsRaw = this.db 967 .query( 968 ` 969 SELECT 970 strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 971 CASE 972 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 973 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 974 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 975 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 976 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 977 ELSE '20:00' 978 END as date, 979 COUNT(*) as count, 980 AVG(response_time) as averageResponseTime 981 FROM request_analytics 982 WHERE timestamp > ? AND endpoint != '/stats' 983 GROUP BY strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 984 CASE 985 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 986 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 987 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 988 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 989 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 990 ELSE '20:00' 991 END 992 ORDER BY date ASC 993 `, 994 ) 995 .all(cutoffTime) as Array<{ 996 date: string; 997 count: number; 998 averageResponseTime: number | null; 999 }>; 1000 1001 timeResults = intervalResultsRaw.map((d) => ({ 1002 date: d.date, 1003 count: d.count, 1004 averageResponseTime: d.averageResponseTime ?? 0, 1005 })); 1006 } 1007 1008 // Average response time (excluding stats) 1009 const avgResponseResult = this.db 1010 .query( 1011 ` 1012 SELECT AVG(response_time) as avg 1013 FROM request_analytics 1014 WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 1015 `, 1016 ) 1017 .get(cutoffTime) as { avg: number | null }; 1018 1019 // Top user agents (raw strings, excluding stats) - optimized with index hint 1020 const topUserAgents = this.db 1021 .query( 1022 ` 1023 SELECT user_agent as userAgent, COUNT(*) as count 1024 FROM request_analytics INDEXED BY idx_request_analytics_user_agent 1025 WHERE timestamp > ? AND user_agent IS NOT NULL AND endpoint != '/stats' 1026 GROUP BY user_agent 1027 ORDER BY count DESC 1028 LIMIT 50 1029 `, 1030 ) 1031 .all(cutoffTime) as Array<{ userAgent: string; count: number }>; 1032 1033 // Enhanced Latency Analytics 1034 1035 // Get all response times for percentile calculations (excluding stats) 1036 const responseTimes = this.db 1037 .query( 1038 ` 1039 SELECT response_time 1040 FROM request_analytics 1041 WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 1042 ORDER BY response_time 1043 `, 1044 ) 1045 .all(cutoffTime) as Array<{ response_time: number }>; 1046 1047 // Calculate percentiles 1048 const calculatePercentile = ( 1049 arr: number[], 1050 percentile: number, 1051 ): number | null => { 1052 if (arr.length === 0) return null; 1053 const index = Math.ceil((percentile / 100) * arr.length) - 1; 1054 return arr[Math.max(0, index)] ?? 0; 1055 }; 1056 1057 const sortedTimes = responseTimes 1058 .map((r) => r.response_time) 1059 .sort((a, b) => a - b); 1060 const percentiles = { 1061 p50: calculatePercentile(sortedTimes, 50), 1062 p75: calculatePercentile(sortedTimes, 75), 1063 p90: calculatePercentile(sortedTimes, 90), 1064 p95: calculatePercentile(sortedTimes, 95), 1065 p99: calculatePercentile(sortedTimes, 99), 1066 }; 1067 1068 // Response time distribution 1069 const totalWithResponseTime = responseTimes.length; 1070 const distributionRanges = [ 1071 { min: 0, max: 50, label: "0-50ms" }, 1072 { min: 50, max: 100, label: "50-100ms" }, 1073 { min: 100, max: 200, label: "100-200ms" }, 1074 { min: 200, max: 500, label: "200-500ms" }, 1075 { min: 500, max: 1000, label: "500ms-1s" }, 1076 { min: 1000, max: 2000, label: "1-2s" }, 1077 { min: 2000, max: 5000, label: "2-5s" }, 1078 { min: 5000, max: Infinity, label: "5s+" }, 1079 ]; 1080 1081 const distribution = distributionRanges.map((range) => { 1082 const count = sortedTimes.filter( 1083 (time) => time >= range.min && time < range.max, 1084 ).length; 1085 return { 1086 range: range.label, 1087 count, 1088 percentage: 1089 totalWithResponseTime > 0 ? (count / totalWithResponseTime) * 100 : 0, 1090 }; 1091 }); 1092 1093 // Slowest endpoints (grouped) 1094 const slowestEndpoints = requestsByEndpoint 1095 .filter((e) => e.averageResponseTime > 0) 1096 .sort((a, b) => b.averageResponseTime - a.averageResponseTime) 1097 .slice(0, 10); 1098 1099 // Latency over time - hourly for 1 day, daily for longer periods 1100 let latencyOverTime: Array<{ 1101 time: string; 1102 averageResponseTime: number; 1103 p95: number | null; 1104 count: number; 1105 }>; 1106 1107 if (days === 1) { 1108 // 15-minute intervals for last 24 hours (excluding stats) 1109 const latencyOverTimeRaw = this.db 1110 .query( 1111 ` 1112 SELECT 1113 strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 1114 CASE 1115 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 1116 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 1117 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 1118 ELSE '45' 1119 END as time, 1120 AVG(response_time) as averageResponseTime, 1121 COUNT(*) as count 1122 FROM request_analytics 1123 WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 1124 GROUP BY strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 1125 CASE 1126 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 1127 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 1128 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 1129 ELSE '45' 1130 END 1131 ORDER BY time ASC 1132 `, 1133 ) 1134 .all(cutoffTime) as Array<{ 1135 time: string; 1136 averageResponseTime: number; 1137 count: number; 1138 }>; 1139 1140 // For 15-minute intervals, we'll skip P95 calculation to improve performance 1141 latencyOverTime = latencyOverTimeRaw.map((intervalData) => ({ 1142 time: intervalData.time, 1143 averageResponseTime: intervalData.averageResponseTime, 1144 p95: null, // Skip P95 for better performance with high granularity 1145 count: intervalData.count, 1146 })); 1147 } else if (days <= 7) { 1148 // Hourly latency data for 7 days (excluding stats) 1149 const latencyOverTimeRaw = this.db 1150 .query( 1151 ` 1152 SELECT 1153 strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) as time, 1154 AVG(response_time) as averageResponseTime, 1155 COUNT(*) as count 1156 FROM request_analytics 1157 WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 1158 GROUP BY strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) 1159 ORDER BY time ASC 1160 `, 1161 ) 1162 .all(cutoffTime) as Array<{ 1163 time: string; 1164 averageResponseTime: number; 1165 count: number; 1166 }>; 1167 1168 latencyOverTime = latencyOverTimeRaw.map((hourData) => ({ 1169 time: hourData.time, 1170 averageResponseTime: hourData.averageResponseTime, 1171 p95: null, // Skip P95 for better performance 1172 count: hourData.count, 1173 })); 1174 } else { 1175 // 4-hour intervals for longer periods (excluding stats) 1176 const latencyOverTimeRaw = this.db 1177 .query( 1178 ` 1179 SELECT 1180 strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 1181 CASE 1182 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 1183 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 1184 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 1185 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 1186 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 1187 ELSE '20:00' 1188 END as time, 1189 AVG(response_time) as averageResponseTime, 1190 COUNT(*) as count 1191 FROM request_analytics 1192 WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 1193 GROUP BY strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 1194 CASE 1195 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 1196 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 1197 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 1198 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 1199 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 1200 ELSE '20:00' 1201 END 1202 ORDER BY time ASC 1203 `, 1204 ) 1205 .all(cutoffTime) as Array<{ 1206 time: string; 1207 averageResponseTime: number; 1208 count: number; 1209 }>; 1210 1211 latencyOverTime = latencyOverTimeRaw.map((intervalData) => ({ 1212 time: intervalData.time, 1213 averageResponseTime: intervalData.averageResponseTime, 1214 p95: null, // Skip P95 for better performance 1215 count: intervalData.count, 1216 })); 1217 } 1218 1219 // Performance Metrics 1220 const errorRequests = statusResults 1221 .filter((s) => s.status >= 400) 1222 .reduce((sum, s) => sum + s.count, 0); 1223 const errorRate = 1224 totalResult.count > 0 ? (errorRequests / totalResult.count) * 100 : 0; 1225 1226 // Calculate throughput (requests per hour) 1227 const timeSpanHours = days * 24; 1228 const throughput = totalResult.count / timeSpanHours; 1229 1230 // Calculate APDEX score (Application Performance Index) 1231 // Satisfied: <= 100ms, Tolerating: <= 400ms, Frustrated: > 400ms 1232 const satisfiedCount = sortedTimes.filter((t) => t <= 100).length; 1233 const toleratingCount = sortedTimes.filter( 1234 (t) => t > 100 && t <= 400, 1235 ).length; 1236 const apdex = 1237 totalWithResponseTime > 0 1238 ? (satisfiedCount + toleratingCount * 0.5) / totalWithResponseTime 1239 : 0; 1240 1241 // Calculate cache hit rate (redirects vs data endpoints) 1242 const redirectRequests = requestsByEndpoint 1243 .filter((e) => 1244 e.endpoint === "User Redirects" || 1245 e.endpoint === "Emoji Redirects") 1246 .reduce((sum, e) => sum + e.count, 0); 1247 const dataRequests = requestsByEndpoint 1248 .filter((e) => 1249 e.endpoint === "User Data" || 1250 e.endpoint === "Emoji Data") 1251 .reduce((sum, e) => sum + e.count, 0); 1252 const cachehitRate = 1253 redirectRequests + dataRequests > 0 1254 ? (redirectRequests / (redirectRequests + dataRequests)) * 100 1255 : 0; 1256 1257 // Simulate uptime (would need actual monitoring data) 1258 const uptime = Math.max(0, 100 - errorRate * 2); // Simple approximation 1259 1260 // Peak traffic analysis (excluding stats) 1261 const peakHourData = this.db 1262 .query( 1263 ` 1264 SELECT 1265 strftime('%H:00', datetime(timestamp / 1000, 'unixepoch')) as hour, 1266 COUNT(*) as count 1267 FROM request_analytics 1268 WHERE timestamp > ? AND endpoint != '/stats' 1269 GROUP BY strftime('%H:00', datetime(timestamp / 1000, 'unixepoch')) 1270 ORDER BY count DESC 1271 LIMIT 1 1272 `, 1273 ) 1274 .get(cutoffTime) as { hour: string; count: number } | null; 1275 1276 const peakDayData = this.db 1277 .query( 1278 ` 1279 SELECT 1280 DATE(timestamp / 1000, 'unixepoch') as day, 1281 COUNT(*) as count 1282 FROM request_analytics 1283 WHERE timestamp > ? AND endpoint != '/stats' 1284 GROUP BY DATE(timestamp / 1000, 'unixepoch') 1285 ORDER BY count DESC 1286 LIMIT 1 1287 `, 1288 ) 1289 .get(cutoffTime) as { day: string; count: number } | null; 1290 1291 // Traffic Overview - detailed route breakdown over time 1292 let trafficOverview: Array<{ 1293 time: string; 1294 routes: Record<string, number>; 1295 total: number; 1296 }>; 1297 1298 if (days === 1) { 1299 // Hourly route breakdown for last 24 hours 1300 const trafficRaw = this.db 1301 .query( 1302 ` 1303 SELECT 1304 strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) as time, 1305 endpoint, 1306 COUNT(*) as count 1307 FROM request_analytics 1308 WHERE timestamp > ? AND endpoint != '/stats' 1309 GROUP BY strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')), endpoint 1310 ORDER BY time ASC 1311 `, 1312 ) 1313 .all(cutoffTime) as Array<{ 1314 time: string; 1315 endpoint: string; 1316 count: number; 1317 }>; 1318 1319 // Group by time and create route breakdown 1320 const timeGroups: Record<string, Record<string, number>> = {}; 1321 for (const row of trafficRaw) { 1322 if (!timeGroups[row.time]) { 1323 timeGroups[row.time] = {}; 1324 } 1325 1326 // Apply same grouping logic as endpoints 1327 let groupKey: string; 1328 const endpoint = row.endpoint; 1329 1330 if (endpoint === "/" || endpoint === "/dashboard") { 1331 groupKey = "Dashboard"; 1332 } else if (endpoint === "/health") { 1333 groupKey = "Health Check"; 1334 } else if (endpoint === "/swagger" || endpoint.startsWith("/swagger")) { 1335 groupKey = "API Documentation"; 1336 } else if (endpoint === "/emojis") { 1337 groupKey = "Emoji List"; 1338 } else if (endpoint.match(/^\/emojis\/[^\/]+$/)) { 1339 groupKey = "Emoji Data"; 1340 } else if (endpoint.match(/^\/emojis\/[^\/]+\/r$/)) { 1341 groupKey = "Emoji Redirects"; 1342 } else if (endpoint.match(/^\/users\/[^\/]+$/)) { 1343 groupKey = "User Data"; 1344 } else if (endpoint.match(/^\/users\/[^\/]+\/r$/)) { 1345 groupKey = "User Redirects"; 1346 } else if (endpoint.match(/^\/users\/[^\/]+\/purge$/)) { 1347 groupKey = "Cache Management"; 1348 } else if (endpoint === "/reset") { 1349 groupKey = "Cache Management"; 1350 } else { 1351 // For any other endpoints, try to categorize them 1352 if (endpoint.includes("/users/") && endpoint.includes("/r")) { 1353 groupKey = "User Redirects"; 1354 } else if (endpoint.includes("/users/")) { 1355 groupKey = "User Data"; 1356 } else if (endpoint.includes("/emojis/") && endpoint.includes("/r")) { 1357 groupKey = "Emoji Redirects"; 1358 } else if (endpoint.includes("/emojis/")) { 1359 groupKey = "Emoji Data"; 1360 } else { 1361 groupKey = "Other"; 1362 } 1363 } 1364 1365 const group = timeGroups[row.time]; 1366 1367 if (group) { 1368 group[groupKey] = (group[groupKey] || 0) + row.count; 1369 } 1370 } 1371 1372 trafficOverview = Object.entries(timeGroups) 1373 .map(([time, routes]) => ({ 1374 time, 1375 routes, 1376 total: Object.values(routes).reduce((sum, count) => sum + count, 0), 1377 })) 1378 .sort((a, b) => a.time.localeCompare(b.time)); 1379 } else if (days <= 7) { 1380 // 4-hour intervals for 7 days 1381 const trafficRaw = this.db 1382 .query( 1383 ` 1384 SELECT 1385 strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) as hour, 1386 endpoint, 1387 COUNT(*) as count 1388 FROM request_analytics 1389 WHERE timestamp > ? AND endpoint != '/stats' 1390 GROUP BY strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')), endpoint 1391 ORDER BY hour ASC 1392 `, 1393 ) 1394 .all(cutoffTime) as Array<{ 1395 hour: string; 1396 endpoint: string; 1397 count: number; 1398 }>; 1399 1400 // Group into 4-hour intervals 1401 const intervalGroups: Record<string, Record<string, number>> = {}; 1402 for (const row of trafficRaw) { 1403 const hourStr = row.hour?.split(" ")[1]?.split(":")[0]; 1404 const hour = hourStr ? parseInt(hourStr) : 0; 1405 const intervalHour = Math.floor(hour / 4) * 4; 1406 const intervalTime = 1407 row.hour.split(" ")[0] + 1408 ` ${intervalHour.toString().padStart(2, "0")}:00`; 1409 1410 if (!intervalGroups[intervalTime]) { 1411 intervalGroups[intervalTime] = {}; 1412 } 1413 1414 // Apply same grouping logic 1415 let groupKey: string; 1416 const endpoint = row.endpoint; 1417 1418 if (endpoint === "/" || endpoint === "/dashboard") { 1419 groupKey = "Dashboard"; 1420 } else if (endpoint === "/health") { 1421 groupKey = "Health Check"; 1422 } else if (endpoint === "/swagger" || endpoint.startsWith("/swagger")) { 1423 groupKey = "API Documentation"; 1424 } else if (endpoint === "/emojis") { 1425 groupKey = "Emoji List"; 1426 } else if (endpoint.match(/^\/emojis\/[^\/]+$/)) { 1427 groupKey = "Emoji Data"; 1428 } else if (endpoint.match(/^\/emojis\/[^\/]+\/r$/)) { 1429 groupKey = "Emoji Redirects"; 1430 } else if (endpoint.match(/^\/users\/[^\/]+$/)) { 1431 groupKey = "User Data"; 1432 } else if (endpoint.match(/^\/users\/[^\/]+\/r$/)) { 1433 groupKey = "User Redirects"; 1434 } else if (endpoint.match(/^\/users\/[^\/]+\/purge$/)) { 1435 groupKey = "Cache Management"; 1436 } else if (endpoint === "/reset") { 1437 groupKey = "Cache Management"; 1438 } else { 1439 // For any other endpoints, try to categorize them 1440 if (endpoint.includes("/users/") && endpoint.includes("/r")) { 1441 groupKey = "User Redirects"; 1442 } else if (endpoint.includes("/users/")) { 1443 groupKey = "User Data"; 1444 } else if (endpoint.includes("/emojis/") && endpoint.includes("/r")) { 1445 groupKey = "Emoji Redirects"; 1446 } else if (endpoint.includes("/emojis/")) { 1447 groupKey = "Emoji Data"; 1448 } else { 1449 groupKey = "Other"; 1450 } 1451 } 1452 1453 intervalGroups[intervalTime][groupKey] = 1454 (intervalGroups[intervalTime][groupKey] || 0) + row.count; 1455 } 1456 1457 trafficOverview = Object.entries(intervalGroups) 1458 .map(([time, routes]) => ({ 1459 time, 1460 routes, 1461 total: Object.values(routes).reduce((sum, count) => sum + count, 0), 1462 })) 1463 .sort((a, b) => a.time.localeCompare(b.time)); 1464 } else { 1465 // Daily breakdown for longer periods 1466 const trafficRaw = this.db 1467 .query( 1468 ` 1469 SELECT 1470 DATE(timestamp / 1000, 'unixepoch') as time, 1471 endpoint, 1472 COUNT(*) as count 1473 FROM request_analytics 1474 WHERE timestamp > ? AND endpoint != '/stats' 1475 GROUP BY DATE(timestamp / 1000, 'unixepoch'), endpoint 1476 ORDER BY time ASC 1477 `, 1478 ) 1479 .all(cutoffTime) as Array<{ 1480 time: string; 1481 endpoint: string; 1482 count: number; 1483 }>; 1484 1485 // Group by day 1486 const dayGroups: Record<string, Record<string, number>> = {}; 1487 for (const row of trafficRaw) { 1488 if (!dayGroups[row.time]) { 1489 dayGroups[row.time] = {}; 1490 } 1491 1492 // Apply same grouping logic 1493 let groupKey: string; 1494 const endpoint = row.endpoint; 1495 1496 if (endpoint === "/" || endpoint === "/dashboard") { 1497 groupKey = "Dashboard"; 1498 } else if (endpoint === "/health") { 1499 groupKey = "Health Check"; 1500 } else if (endpoint === "/swagger" || endpoint.startsWith("/swagger")) { 1501 groupKey = "API Documentation"; 1502 } else if (endpoint === "/emojis") { 1503 groupKey = "Emoji List"; 1504 } else if (endpoint.match(/^\/emojis\/[^\/]+$/)) { 1505 groupKey = "Emoji Data"; 1506 } else if (endpoint.match(/^\/emojis\/[^\/]+\/r$/)) { 1507 groupKey = "Emoji Redirects"; 1508 } else if (endpoint.match(/^\/users\/[^\/]+$/)) { 1509 groupKey = "User Data"; 1510 } else if (endpoint.match(/^\/users\/[^\/]+\/r$/)) { 1511 groupKey = "User Redirects"; 1512 } else if (endpoint.match(/^\/users\/[^\/]+\/purge$/)) { 1513 groupKey = "Cache Management"; 1514 } else if (endpoint === "/reset") { 1515 groupKey = "Cache Management"; 1516 } else { 1517 // For any other endpoints, try to categorize them 1518 if (endpoint.includes("/users/") && endpoint.includes("/r")) { 1519 groupKey = "User Redirects"; 1520 } else if (endpoint.includes("/users/")) { 1521 groupKey = "User Data"; 1522 } else if (endpoint.includes("/emojis/") && endpoint.includes("/r")) { 1523 groupKey = "Emoji Redirects"; 1524 } else if (endpoint.includes("/emojis/")) { 1525 groupKey = "Emoji Data"; 1526 } else { 1527 groupKey = "Other"; 1528 } 1529 } 1530 const group = dayGroups[row.time]; 1531 if (group) { 1532 group[groupKey] = (group[groupKey] || 0) + row.count; 1533 } 1534 } 1535 1536 trafficOverview = Object.entries(dayGroups) 1537 .map(([time, routes]) => ({ 1538 time, 1539 routes, 1540 total: Object.values(routes).reduce((sum, count) => sum + count, 0), 1541 })) 1542 .sort((a, b) => a.time.localeCompare(b.time)); 1543 } 1544 1545 const result = { 1546 totalRequests: totalResult.count, 1547 requestsByEndpoint: requestsByEndpoint, 1548 requestsByStatus: statusResults, 1549 requestsByDay: timeResults, 1550 averageResponseTime: avgResponseResult.avg, 1551 topUserAgents: topUserAgents, 1552 latencyAnalytics: { 1553 percentiles, 1554 distribution, 1555 slowestEndpoints, 1556 latencyOverTime, 1557 }, 1558 performanceMetrics: { 1559 uptime, 1560 errorRate, 1561 throughput, 1562 apdex, 1563 cachehitRate, 1564 }, 1565 peakTraffic: { 1566 peakHour: peakHourData?.hour || "N/A", 1567 peakRequests: peakHourData?.count || 0, 1568 peakDay: peakDayData?.day || "N/A", 1569 peakDayRequests: peakDayData?.count || 0, 1570 }, 1571 dashboardMetrics: { 1572 statsRequests: statsResult.count, 1573 totalWithStats: totalResult.count + statsResult.count, 1574 }, 1575 trafficOverview, 1576 }; 1577 1578 // Cache the result 1579 this.analyticsCache.set(cacheKey, { 1580 data: result, 1581 timestamp: now 1582 }); 1583 1584 // Clean up old cache entries (keep only last 5) 1585 if (this.analyticsCache.size > 5) { 1586 const keys = Array.from(this.analyticsCache.keys()); 1587 const oldestKey = keys[0]; 1588 if (oldestKey) { 1589 this.analyticsCache.delete(oldestKey); 1590 } 1591 } 1592 1593 return result; 1594 } 1595 1596 /** 1597 * Gets essential stats only (fast loading) 1598 * @param days Number of days to look back (default: 7) 1599 * @returns Essential stats data 1600 */ 1601 async getEssentialStats(days: number = 7): Promise<{ 1602 totalRequests: number; 1603 averageResponseTime: number | null; 1604 uptime: number; 1605 }> { 1606 // Check cache first 1607 const cacheKey = `essential_${days}`; 1608 const cached = this.analyticsCache.get(cacheKey); 1609 const now = Date.now(); 1610 1611 if (cached && (now - cached.timestamp) < this.analyticsCacheTTL) { 1612 return cached.data; 1613 } 1614 1615 const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000; 1616 1617 // Total requests (excluding stats endpoint) - fastest query 1618 const totalResult = this.db 1619 .query( 1620 "SELECT COUNT(*) as count FROM request_analytics WHERE timestamp > ? AND endpoint != '/stats'", 1621 ) 1622 .get(cutoffTime) as { count: number }; 1623 1624 // Average response time (excluding stats) - simple query 1625 const avgResponseResult = this.db 1626 .query( 1627 "SELECT AVG(response_time) as avg FROM request_analytics WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats'", 1628 ) 1629 .get(cutoffTime) as { avg: number | null }; 1630 1631 // Simple error rate calculation for uptime 1632 const errorRequests = this.db 1633 .query( 1634 "SELECT COUNT(*) as count FROM request_analytics WHERE timestamp > ? AND status_code >= 400 AND endpoint != '/stats'", 1635 ) 1636 .get(cutoffTime) as { count: number }; 1637 1638 const errorRate = totalResult.count > 0 ? (errorRequests.count / totalResult.count) * 100 : 0; 1639 const uptime = Math.max(0, 100 - errorRate * 2); // Simple approximation 1640 1641 const result = { 1642 totalRequests: totalResult.count, 1643 averageResponseTime: avgResponseResult.avg, 1644 uptime: uptime, 1645 }; 1646 1647 // Cache the result 1648 this.analyticsCache.set(cacheKey, { 1649 data: result, 1650 timestamp: now 1651 }); 1652 1653 return result; 1654 } 1655 1656 /** 1657 * Gets chart data only (requests and latency over time) 1658 * @param days Number of days to look back (default: 7) 1659 * @returns Chart data 1660 */ 1661 async getChartData(days: number = 7): Promise<{ 1662 requestsByDay: Array<{ 1663 date: string; 1664 count: number; 1665 averageResponseTime: number; 1666 }>; 1667 latencyOverTime: Array<{ 1668 time: string; 1669 averageResponseTime: number; 1670 p95: number | null; 1671 count: number; 1672 }>; 1673 }> { 1674 // Check cache first 1675 const cacheKey = `charts_${days}`; 1676 const cached = this.analyticsCache.get(cacheKey); 1677 const now = Date.now(); 1678 1679 if (cached && (now - cached.timestamp) < this.analyticsCacheTTL) { 1680 return cached.data; 1681 } 1682 1683 const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000; 1684 1685 // Reuse the existing time logic from getAnalytics 1686 let timeResults: Array<{ 1687 date: string; 1688 count: number; 1689 averageResponseTime: number; 1690 }>; 1691 1692 if (days === 1) { 1693 // 15-minute intervals for last 24 hours (excluding stats) 1694 const intervalResultsRaw = this.db 1695 .query( 1696 ` 1697 SELECT 1698 strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 1699 CASE 1700 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 1701 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 1702 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 1703 ELSE '45' 1704 END as date, 1705 COUNT(*) as count, 1706 AVG(response_time) as averageResponseTime 1707 FROM request_analytics 1708 WHERE timestamp > ? AND endpoint != '/stats' 1709 GROUP BY strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 1710 CASE 1711 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 1712 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 1713 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 1714 ELSE '45' 1715 END 1716 ORDER BY date ASC 1717 `, 1718 ) 1719 .all(cutoffTime) as Array<{ 1720 date: string; 1721 count: number; 1722 averageResponseTime: number | null; 1723 }>; 1724 1725 timeResults = intervalResultsRaw.map((h) => ({ 1726 date: h.date, 1727 count: h.count, 1728 averageResponseTime: h.averageResponseTime ?? 0, 1729 })); 1730 } else if (days <= 7) { 1731 // Hourly data for 7 days (excluding stats) 1732 const hourResultsRaw = this.db 1733 .query( 1734 ` 1735 SELECT 1736 strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) as date, 1737 COUNT(*) as count, 1738 AVG(response_time) as averageResponseTime 1739 FROM request_analytics 1740 WHERE timestamp > ? AND endpoint != '/stats' 1741 GROUP BY strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) 1742 ORDER BY date ASC 1743 `, 1744 ) 1745 .all(cutoffTime) as Array<{ 1746 date: string; 1747 count: number; 1748 averageResponseTime: number | null; 1749 }>; 1750 1751 timeResults = hourResultsRaw.map((h) => ({ 1752 date: h.date, 1753 count: h.count, 1754 averageResponseTime: h.averageResponseTime ?? 0, 1755 })); 1756 } else { 1757 // 4-hour intervals for longer periods (excluding stats) 1758 const intervalResultsRaw = this.db 1759 .query( 1760 ` 1761 SELECT 1762 strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 1763 CASE 1764 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 1765 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 1766 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 1767 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 1768 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 1769 ELSE '20:00' 1770 END as date, 1771 COUNT(*) as count, 1772 AVG(response_time) as averageResponseTime 1773 FROM request_analytics 1774 WHERE timestamp > ? AND endpoint != '/stats' 1775 GROUP BY strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 1776 CASE 1777 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 1778 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 1779 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 1780 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 1781 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 1782 ELSE '20:00' 1783 END 1784 ORDER BY date ASC 1785 `, 1786 ) 1787 .all(cutoffTime) as Array<{ 1788 date: string; 1789 count: number; 1790 averageResponseTime: number | null; 1791 }>; 1792 1793 timeResults = intervalResultsRaw.map((d) => ({ 1794 date: d.date, 1795 count: d.count, 1796 averageResponseTime: d.averageResponseTime ?? 0, 1797 })); 1798 } 1799 1800 // Latency over time data (reuse from getAnalytics) 1801 let latencyOverTime: Array<{ 1802 time: string; 1803 averageResponseTime: number; 1804 p95: number | null; 1805 count: number; 1806 }>; 1807 1808 if (days === 1) { 1809 const latencyOverTimeRaw = this.db 1810 .query( 1811 ` 1812 SELECT 1813 strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 1814 CASE 1815 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 1816 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 1817 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 1818 ELSE '45' 1819 END as time, 1820 AVG(response_time) as averageResponseTime, 1821 COUNT(*) as count 1822 FROM request_analytics 1823 WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 1824 GROUP BY strftime('%Y-%m-%d %H:', datetime(timestamp / 1000, 'unixepoch')) || 1825 CASE 1826 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 15 THEN '00' 1827 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 30 THEN '15' 1828 WHEN CAST(strftime('%M', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 45 THEN '30' 1829 ELSE '45' 1830 END 1831 ORDER BY time ASC 1832 `, 1833 ) 1834 .all(cutoffTime) as Array<{ 1835 time: string; 1836 averageResponseTime: number; 1837 count: number; 1838 }>; 1839 1840 latencyOverTime = latencyOverTimeRaw.map((intervalData) => ({ 1841 time: intervalData.time, 1842 averageResponseTime: intervalData.averageResponseTime, 1843 p95: null, // Skip P95 for better performance 1844 count: intervalData.count, 1845 })); 1846 } else if (days <= 7) { 1847 const latencyOverTimeRaw = this.db 1848 .query( 1849 ` 1850 SELECT 1851 strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) as time, 1852 AVG(response_time) as averageResponseTime, 1853 COUNT(*) as count 1854 FROM request_analytics 1855 WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 1856 GROUP BY strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) 1857 ORDER BY time ASC 1858 `, 1859 ) 1860 .all(cutoffTime) as Array<{ 1861 time: string; 1862 averageResponseTime: number; 1863 count: number; 1864 }>; 1865 1866 latencyOverTime = latencyOverTimeRaw.map((hourData) => ({ 1867 time: hourData.time, 1868 averageResponseTime: hourData.averageResponseTime, 1869 p95: null, // Skip P95 for better performance 1870 count: hourData.count, 1871 })); 1872 } else { 1873 const latencyOverTimeRaw = this.db 1874 .query( 1875 ` 1876 SELECT 1877 strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 1878 CASE 1879 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 1880 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 1881 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 1882 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 1883 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 1884 ELSE '20:00' 1885 END as time, 1886 AVG(response_time) as averageResponseTime, 1887 COUNT(*) as count 1888 FROM request_analytics 1889 WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 1890 GROUP BY strftime('%Y-%m-%d ', datetime(timestamp / 1000, 'unixepoch')) || 1891 CASE 1892 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 4 THEN '00:00' 1893 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 8 THEN '04:00' 1894 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 12 THEN '08:00' 1895 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 16 THEN '12:00' 1896 WHEN CAST(strftime('%H', datetime(timestamp / 1000, 'unixepoch')) AS INTEGER) < 20 THEN '16:00' 1897 ELSE '20:00' 1898 END 1899 ORDER BY time ASC 1900 `, 1901 ) 1902 .all(cutoffTime) as Array<{ 1903 time: string; 1904 averageResponseTime: number; 1905 count: number; 1906 }>; 1907 1908 latencyOverTime = latencyOverTimeRaw.map((intervalData) => ({ 1909 time: intervalData.time, 1910 averageResponseTime: intervalData.averageResponseTime, 1911 p95: null, // Skip P95 for better performance 1912 count: intervalData.count, 1913 })); 1914 } 1915 1916 const result = { 1917 requestsByDay: timeResults, 1918 latencyOverTime: latencyOverTime, 1919 }; 1920 1921 // Cache the result 1922 this.analyticsCache.set(cacheKey, { 1923 data: result, 1924 timestamp: now 1925 }); 1926 1927 return result; 1928 } 1929 1930 /** 1931 * Gets user agents data only (slowest loading) 1932 * @param days Number of days to look back (default: 7) 1933 * @returns User agents data 1934 */ 1935 async getUserAgents(days: number = 7): Promise<Array<{ userAgent: string; count: number }>> { 1936 // Check cache first 1937 const cacheKey = `useragents_${days}`; 1938 const cached = this.analyticsCache.get(cacheKey); 1939 const now = Date.now(); 1940 1941 if (cached && (now - cached.timestamp) < this.analyticsCacheTTL) { 1942 return cached.data; 1943 } 1944 1945 const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000; 1946 1947 // Top user agents (raw strings, excluding stats) - optimized with index hint 1948 const topUserAgents = this.db 1949 .query( 1950 ` 1951 SELECT user_agent as userAgent, COUNT(*) as count 1952 FROM request_analytics INDEXED BY idx_request_analytics_user_agent 1953 WHERE timestamp > ? AND user_agent IS NOT NULL AND endpoint != '/stats' 1954 GROUP BY user_agent 1955 ORDER BY count DESC 1956 LIMIT 50 1957 `, 1958 ) 1959 .all(cutoffTime) as Array<{ userAgent: string; count: number }>; 1960 1961 // Cache the result 1962 this.analyticsCache.set(cacheKey, { 1963 data: topUserAgents, 1964 timestamp: now 1965 }); 1966 1967 return topUserAgents; 1968 } 1969} 1970 1971export { Cache as SlackCache };