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 50 /** 51 * Creates a new Cache instance 52 * @param dbPath Path to SQLite database file 53 * @param defaultExpirationHours Default cache expiration in hours 54 * @param onEmojiExpired Optional callback function called when emojis expire 55 */ 56 constructor( 57 dbPath: string, 58 defaultExpirationHours = 24, 59 onEmojiExpired?: () => void, 60 ) { 61 this.db = new Database(dbPath); 62 this.defaultExpiration = defaultExpirationHours; 63 this.onEmojiExpired = onEmojiExpired; 64 65 this.initDatabase(); 66 this.setupPurgeSchedule(); 67 68 // Run migrations 69 this.runMigrations(); 70 } 71 72 /** 73 * Initializes the database tables 74 * @private 75 */ 76 private initDatabase() { 77 // Create users table 78 this.db.run(` 79 CREATE TABLE IF NOT EXISTS users ( 80 id TEXT PRIMARY KEY, 81 userId TEXT UNIQUE, 82 displayName TEXT, 83 pronouns TEXT, 84 imageUrl TEXT, 85 expiration INTEGER 86 ) 87 `); 88 89 // Create emojis table 90 this.db.run(` 91 CREATE TABLE IF NOT EXISTS emojis ( 92 id TEXT PRIMARY KEY, 93 name TEXT UNIQUE, 94 alias TEXT, 95 imageUrl TEXT, 96 expiration INTEGER 97 ) 98 `); 99 100 // Create request analytics table 101 this.db.run(` 102 CREATE TABLE IF NOT EXISTS request_analytics ( 103 id TEXT PRIMARY KEY, 104 endpoint TEXT NOT NULL, 105 method TEXT NOT NULL, 106 status_code INTEGER NOT NULL, 107 user_agent TEXT, 108 ip_address TEXT, 109 timestamp INTEGER NOT NULL, 110 response_time INTEGER 111 ) 112 `); 113 114 // Create index for faster queries 115 this.db.run(` 116 CREATE INDEX IF NOT EXISTS idx_request_analytics_timestamp 117 ON request_analytics(timestamp) 118 `); 119 120 this.db.run(` 121 CREATE INDEX IF NOT EXISTS idx_request_analytics_endpoint 122 ON request_analytics(endpoint) 123 `); 124 125 // check if there are any emojis in the db 126 if (this.onEmojiExpired) { 127 const result = this.db 128 .query("SELECT COUNT(*) as count FROM emojis WHERE expiration > ?") 129 .get(Date.now()) as { count: number }; 130 if (result.count === 0) { 131 this.onEmojiExpired(); 132 } 133 } 134 } 135 136 /** 137 * Sets up hourly purge of expired items 138 * @private 139 */ 140 private setupPurgeSchedule() { 141 // Run purge every hour 142 schedule("45 * * * *", async () => { 143 await this.purgeExpiredItems(); 144 }); 145 } 146 147 /** 148 * Run database migrations 149 * @private 150 */ 151 private async runMigrations() { 152 try { 153 const migrations = [ 154 endpointGroupingMigration, 155 logGroupingMigration 156 ]; 157 const migrationManager = new MigrationManager(this.db, migrations); 158 const result = await migrationManager.runMigrations(); 159 160 if (result.migrationsApplied > 0) { 161 console.log(`Applied ${result.migrationsApplied} migrations. Latest version: ${result.lastAppliedVersion}`); 162 } else { 163 console.log("No new migrations to apply"); 164 } 165 } catch (error) { 166 console.error("Error running migrations:", error); 167 } 168 } 169 170 /** 171 * Purges expired items from the cache 172 * @returns int indicating number of items purged 173 */ 174 async purgeExpiredItems(): Promise<number> { 175 const result = this.db.run("DELETE FROM users WHERE expiration < ?", [ 176 Date.now(), 177 ]); 178 const result2 = this.db.run("DELETE FROM emojis WHERE expiration < ?", [ 179 Date.now(), 180 ]); 181 182 // Clean up old analytics data (older than 30 days) 183 const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; 184 this.db.run("DELETE FROM request_analytics WHERE timestamp < ?", [ 185 thirtyDaysAgo, 186 ]); 187 188 if (this.onEmojiExpired) { 189 if (result2.changes > 0) { 190 this.onEmojiExpired(); 191 } 192 } 193 194 return result.changes + result2.changes; 195 } 196 197 /** 198 * Purges cache for a specific user 199 * @param userId The Slack user ID to purge from cache 200 * @returns boolean indicating if any user was purged 201 */ 202 async purgeUserCache(userId: string): Promise<boolean> { 203 try { 204 const result = this.db.run("DELETE FROM users WHERE userId = ?", [ 205 userId.toUpperCase(), 206 ]); 207 return result.changes > 0; 208 } catch (error) { 209 console.error("Error purging user cache:", error); 210 return false; 211 } 212 } 213 214 /** 215 * Purges all items from the cache 216 * @returns Object containing purge results 217 */ 218 async purgeAll(): Promise<{ 219 message: string; 220 users: number; 221 emojis: number; 222 }> { 223 const result = this.db.run("DELETE FROM users"); 224 const result2 = this.db.run("DELETE FROM emojis"); 225 226 if (this.onEmojiExpired) { 227 if (result2.changes > 0) { 228 this.onEmojiExpired(); 229 } 230 } 231 232 return { 233 message: "Cache purged", 234 users: result.changes, 235 emojis: result2.changes, 236 }; 237 } 238 239 /** 240 * Checks if the cache is healthy by testing database connectivity 241 * @returns boolean indicating if cache is healthy 242 */ 243 async healthCheck(): Promise<boolean> { 244 try { 245 this.db.query("SELECT 1").get(); 246 return true; 247 } catch (error) { 248 console.error("Cache health check failed:", error); 249 return false; 250 } 251 } 252 253 /** 254 * Inserts a user into the cache 255 * @param userId Unique identifier for the user 256 * @param imageUrl URL of the user's image 257 * @param expirationHours Optional custom expiration time in hours 258 * @returns boolean indicating success 259 */ 260 async insertUser( 261 userId: string, 262 displayName: string, 263 pronouns: string, 264 imageUrl: string, 265 expirationHours?: number, 266 ) { 267 const id = crypto.randomUUID(); 268 const expiration = 269 Date.now() + (expirationHours || this.defaultExpiration) * 3600000; 270 271 try { 272 this.db.run( 273 `INSERT INTO users (id, userId, displayName, pronouns, imageUrl, expiration) 274 VALUES (?, ?, ?, ?, ?, ?) 275 ON CONFLICT(userId) 276 DO UPDATE SET imageUrl = ?, expiration = ?`, 277 [ 278 id, 279 userId.toUpperCase(), 280 displayName, 281 pronouns, 282 imageUrl, 283 expiration, 284 imageUrl, 285 expiration, 286 ], 287 ); 288 return true; 289 } catch (error) { 290 console.error("Error inserting/updating user:", error); 291 return false; 292 } 293 } 294 295 /** 296 * Inserts an emoji into the cache 297 * @param name Name of the emoji 298 * @param imageUrl URL of the emoji image 299 * @param expirationHours Optional custom expiration time in hours 300 * @returns boolean indicating success 301 */ 302 async insertEmoji( 303 name: string, 304 alias: string | null, 305 imageUrl: string, 306 expirationHours?: number, 307 ) { 308 const id = crypto.randomUUID(); 309 const expiration = 310 Date.now() + (expirationHours || this.defaultExpiration) * 3600000; 311 312 try { 313 this.db.run( 314 `INSERT INTO emojis (id, name, alias, imageUrl, expiration) 315 VALUES (?, ?, ?, ?, ?) 316 ON CONFLICT(name) 317 DO UPDATE SET imageUrl = ?, expiration = ?`, 318 [ 319 id, 320 name.toLowerCase(), 321 alias?.toLowerCase() || null, 322 imageUrl, 323 expiration, 324 imageUrl, 325 expiration, 326 ], 327 ); 328 return true; 329 } catch (error) { 330 console.error("Error inserting/updating emoji:", error); 331 return false; 332 } 333 } 334 335 /** 336 * Batch inserts multiple emojis into the cache 337 * @param emojis Array of {name, imageUrl} objects to insert 338 * @param expirationHours Optional custom expiration time in hours for all emojis 339 * @returns boolean indicating if all insertions were successful 340 */ 341 async batchInsertEmojis( 342 emojis: Array<{ name: string; imageUrl: string; alias: string | null }>, 343 expirationHours?: number, 344 ): Promise<boolean> { 345 try { 346 const expiration = 347 Date.now() + (expirationHours || this.defaultExpiration) * 3600000; 348 349 this.db.transaction(() => { 350 for (const emoji of emojis) { 351 const id = crypto.randomUUID(); 352 this.db.run( 353 `INSERT INTO emojis (id, name, alias, imageUrl, expiration) 354 VALUES (?, ?, ?, ?, ?) 355 ON CONFLICT(name) 356 DO UPDATE SET imageUrl = ?, expiration = ?`, 357 [ 358 id, 359 emoji.name.toLowerCase(), 360 emoji.alias?.toLowerCase() || null, 361 emoji.imageUrl, 362 expiration, 363 emoji.imageUrl, 364 expiration, 365 ], 366 ); 367 } 368 })(); 369 370 return true; 371 } catch (error) { 372 console.error("Error batch inserting emojis:", error); 373 return false; 374 } 375 } 376 377 /** 378 * Lists all emoji in the cache 379 * @returns Array of Emoji objects that haven't expired 380 */ 381 async listEmojis(): Promise<Emoji[]> { 382 const results = this.db 383 .query("SELECT * FROM emojis WHERE expiration > ?") 384 .all(Date.now()) as Emoji[]; 385 386 return results.map((result) => ({ 387 type: "emoji", 388 id: result.id, 389 name: result.name, 390 alias: result.alias || null, 391 imageUrl: result.imageUrl, 392 expiration: new Date(result.expiration), 393 })); 394 } 395 396 /** 397 * Retrieves a user from the cache 398 * @param userId Unique identifier of the user 399 * @returns User object if found and not expired, null otherwise 400 */ 401 async getUser(userId: string): Promise<User | null> { 402 const result = this.db 403 .query("SELECT * FROM users WHERE userId = ?") 404 .get(userId.toUpperCase()) as User; 405 406 if (!result) { 407 return null; 408 } 409 410 if (new Date(result.expiration).getTime() < Date.now()) { 411 this.db.run("DELETE FROM users WHERE userId = ?", [userId]); 412 return null; 413 } 414 415 return { 416 type: "user", 417 id: result.id, 418 userId: result.userId, 419 displayName: result.displayName, 420 pronouns: result.pronouns, 421 imageUrl: result.imageUrl, 422 expiration: new Date(result.expiration), 423 }; 424 } 425 426 /** 427 * Retrieves an emoji from the cache 428 * @param name Name of the emoji 429 * @returns Emoji object if found and not expired, null otherwise 430 */ 431 async getEmoji(name: string): Promise<Emoji | null> { 432 const result = this.db 433 .query("SELECT * FROM emojis WHERE name = ? AND expiration > ?") 434 .get(name.toLowerCase(), Date.now()) as Emoji; 435 436 return result 437 ? { 438 type: "emoji", 439 id: result.id, 440 name: result.name, 441 alias: result.alias || null, 442 imageUrl: result.imageUrl, 443 expiration: new Date(result.expiration), 444 } 445 : null; 446 } 447 448 /** 449 * Records a request for analytics 450 * @param endpoint The endpoint that was accessed 451 * @param method HTTP method 452 * @param statusCode HTTP status code 453 * @param userAgent User agent string 454 * @param ipAddress IP address of the client 455 * @param responseTime Response time in milliseconds 456 */ 457 async recordRequest( 458 endpoint: string, 459 method: string, 460 statusCode: number, 461 userAgent?: string, 462 ipAddress?: string, 463 responseTime?: number, 464 ): Promise<void> { 465 try { 466 const id = crypto.randomUUID(); 467 this.db.run( 468 `INSERT INTO request_analytics 469 (id, endpoint, method, status_code, user_agent, ip_address, timestamp, response_time) 470 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 471 [ 472 id, 473 endpoint, 474 method, 475 statusCode, 476 userAgent || null, 477 ipAddress || null, 478 Date.now(), 479 responseTime || null, 480 ], 481 ); 482 } catch (error) { 483 console.error("Error recording request analytics:", error); 484 } 485 } 486 487 /** 488 * Gets request analytics statistics 489 * @param days Number of days to look back (default: 7) 490 * @returns Analytics data 491 */ 492 async getAnalytics(days: number = 7): Promise<{ 493 totalRequests: number; 494 requestsByEndpoint: Array<{ 495 endpoint: string; 496 count: number; 497 averageResponseTime: number; 498 }>; 499 requestsByStatus: Array<{ 500 status: number; 501 count: number; 502 averageResponseTime: number; 503 }>; 504 requestsByDay: Array<{ 505 date: string; 506 count: number; 507 averageResponseTime: number; 508 }>; 509 averageResponseTime: number | null; 510 topUserAgents: Array<{ userAgent: string; count: number }>; 511 latencyAnalytics: { 512 percentiles: { 513 p50: number | null; 514 p75: number | null; 515 p90: number | null; 516 p95: number | null; 517 p99: number | null; 518 }; 519 distribution: Array<{ 520 range: string; 521 count: number; 522 percentage: number; 523 }>; 524 slowestEndpoints: Array<{ 525 endpoint: string; 526 averageResponseTime: number; 527 count: number; 528 }>; 529 latencyOverTime: Array<{ 530 time: string; 531 averageResponseTime: number; 532 p95: number | null; 533 count: number; 534 }>; 535 }; 536 performanceMetrics: { 537 uptime: number; 538 errorRate: number; 539 throughput: number; 540 apdex: number; 541 cachehitRate: number; 542 }; 543 peakTraffic: { 544 peakHour: string; 545 peakRequests: number; 546 peakDay: string; 547 peakDayRequests: number; 548 }; 549 dashboardMetrics: { 550 statsRequests: number; 551 totalWithStats: number; 552 }; 553 trafficOverview: Array<{ 554 time: string; 555 routes: Record<string, number>; 556 total: number; 557 }>; 558 }> { 559 const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000; 560 561 // Total requests (excluding stats endpoint) 562 const totalResult = this.db 563 .query( 564 "SELECT COUNT(*) as count FROM request_analytics WHERE timestamp > ? AND endpoint != '/stats'", 565 ) 566 .get(cutoffTime) as { count: number }; 567 568 // Stats endpoint requests (tracked separately) 569 const statsResult = this.db 570 .query( 571 "SELECT COUNT(*) as count FROM request_analytics WHERE timestamp > ? AND endpoint = '/stats'", 572 ) 573 .get(cutoffTime) as { count: number }; 574 575 // Get raw endpoint data and group them intelligently (excluding stats) 576 const rawEndpointResults = this.db 577 .query( 578 ` 579 SELECT endpoint, COUNT(*) as count, AVG(response_time) as averageResponseTime 580 FROM request_analytics 581 WHERE timestamp > ? AND endpoint != '/stats' 582 GROUP BY endpoint 583 ORDER BY count DESC 584 `, 585 ) 586 .all(cutoffTime) as Array<{ 587 endpoint: string; 588 count: number; 589 averageResponseTime: number | null; 590 }>; 591 592 // Group endpoints intelligently 593 const endpointGroups: Record< 594 string, 595 { count: number; totalResponseTime: number; requestCount: number } 596 > = {}; 597 598 for (const result of rawEndpointResults) { 599 const endpoint = result.endpoint; 600 let groupKey: string; 601 602 if (endpoint === "/" || endpoint === "/dashboard") { 603 groupKey = "Dashboard"; 604 } else if (endpoint === "/health") { 605 groupKey = "Health Check"; 606 } else if (endpoint === "/swagger" || endpoint.startsWith("/swagger")) { 607 groupKey = "API Documentation"; 608 } else if (endpoint === "/emojis") { 609 groupKey = "Emoji List"; 610 } else if (endpoint.match(/^\/emojis\/[^\/]+$/)) { 611 groupKey = "Emoji Data"; 612 } else if (endpoint.match(/^\/emojis\/[^\/]+\/r$/)) { 613 groupKey = "Emoji Redirects"; 614 } else if (endpoint.match(/^\/users\/[^\/]+$/)) { 615 groupKey = "User Data"; 616 } else if (endpoint.match(/^\/users\/[^\/]+\/r$/)) { 617 groupKey = "User Redirects"; 618 } else if (endpoint.match(/^\/users\/[^\/]+\/purge$/)) { 619 groupKey = "Cache Management"; 620 } else if (endpoint === "/reset") { 621 groupKey = "Cache Management"; 622 } else { 623 // For any other endpoints, try to categorize them 624 if (endpoint.includes("/users/") && endpoint.includes("/r")) { 625 groupKey = "User Redirects"; 626 } else if (endpoint.includes("/users/")) { 627 groupKey = "User Data"; 628 } else if (endpoint.includes("/emojis/") && endpoint.includes("/r")) { 629 groupKey = "Emoji Redirects"; 630 } else if (endpoint.includes("/emojis/")) { 631 groupKey = "Emoji Data"; 632 } else { 633 groupKey = "Other"; 634 } 635 } 636 637 if (!endpointGroups[groupKey]) { 638 endpointGroups[groupKey] = { 639 count: 0, 640 totalResponseTime: 0, 641 requestCount: 0, 642 }; 643 } 644 645 // Defensive: Only update if groupKey exists (should always exist due to initialization above) 646 const group = endpointGroups[groupKey]; 647 if (group) { 648 group.count += result.count; 649 if ( 650 result.averageResponseTime !== null && 651 result.averageResponseTime !== undefined 652 ) { 653 group.totalResponseTime += result.averageResponseTime * result.count; 654 group.requestCount += result.count; 655 } 656 } 657 } 658 659 // Convert back to array format with calculated averages 660 const requestsByEndpoint = Object.entries(endpointGroups) 661 .map(([endpoint, data]) => ({ 662 endpoint, 663 count: data.count, 664 averageResponseTime: 665 data.requestCount > 0 666 ? data.totalResponseTime / data.requestCount 667 : 0, 668 })) 669 .sort((a, b) => b.count - a.count); 670 671 // Requests by status code with average response time (excluding stats) 672 const statusResultsRaw = this.db 673 .query( 674 ` 675 SELECT status_code as status, COUNT(*) as count, AVG(response_time) as averageResponseTime 676 FROM request_analytics 677 WHERE timestamp > ? AND endpoint != '/stats' 678 GROUP BY status_code 679 ORDER BY count DESC 680 `, 681 ) 682 .all(cutoffTime) as Array<{ 683 status: number; 684 count: number; 685 averageResponseTime: number | null; 686 }>; 687 688 const statusResults = statusResultsRaw.map((s) => ({ 689 status: s.status, 690 count: s.count, 691 averageResponseTime: s.averageResponseTime ?? 0, 692 })); 693 694 // Requests over time - hourly for 1 day, daily for longer periods 695 let timeResults: Array<{ 696 date: string; 697 count: number; 698 averageResponseTime: number; 699 }>; 700 701 if (days === 1) { 702 // Hourly data for last 24 hours (excluding stats) 703 const hourResultsRaw = this.db 704 .query( 705 ` 706 SELECT 707 strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) as date, 708 COUNT(*) as count, 709 AVG(response_time) as averageResponseTime 710 FROM request_analytics 711 WHERE timestamp > ? AND endpoint != '/stats' 712 GROUP BY strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) 713 ORDER BY date ASC 714 `, 715 ) 716 .all(cutoffTime) as Array<{ 717 date: string; 718 count: number; 719 averageResponseTime: number | null; 720 }>; 721 722 timeResults = hourResultsRaw.map((h) => ({ 723 date: h.date, 724 count: h.count, 725 averageResponseTime: h.averageResponseTime ?? 0, 726 })); 727 } else { 728 // Daily data for longer periods (excluding stats) 729 const dayResultsRaw = this.db 730 .query( 731 ` 732 SELECT 733 DATE(timestamp / 1000, 'unixepoch') as date, 734 COUNT(*) as count, 735 AVG(response_time) as averageResponseTime 736 FROM request_analytics 737 WHERE timestamp > ? AND endpoint != '/stats' 738 GROUP BY DATE(timestamp / 1000, 'unixepoch') 739 ORDER BY date ASC 740 `, 741 ) 742 .all(cutoffTime) as Array<{ 743 date: string; 744 count: number; 745 averageResponseTime: number | null; 746 }>; 747 748 timeResults = dayResultsRaw.map((d) => ({ 749 date: d.date, 750 count: d.count, 751 averageResponseTime: d.averageResponseTime ?? 0, 752 })); 753 } 754 755 // Average response time (excluding stats) 756 const avgResponseResult = this.db 757 .query( 758 ` 759 SELECT AVG(response_time) as avg 760 FROM request_analytics 761 WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 762 `, 763 ) 764 .get(cutoffTime) as { avg: number | null }; 765 766 // Top user agents (simplified and grouped, excluding stats) 767 const rawUserAgentResults = this.db 768 .query( 769 ` 770 SELECT user_agent as userAgent, COUNT(*) as count 771 FROM request_analytics 772 WHERE timestamp > ? AND user_agent IS NOT NULL AND endpoint != '/stats' 773 GROUP BY user_agent 774 ORDER BY count DESC 775 LIMIT 20 776 `, 777 ) 778 .all(cutoffTime) as Array<{ userAgent: string; count: number }>; 779 780 // Group user agents intelligently 781 const userAgentGroups: Record<string, number> = {}; 782 783 for (const result of rawUserAgentResults) { 784 const ua = result.userAgent.toLowerCase(); 785 let groupKey: string; 786 787 if (ua.includes("chrome") && !ua.includes("edg")) { 788 groupKey = "Chrome"; 789 } else if (ua.includes("firefox")) { 790 groupKey = "Firefox"; 791 } else if (ua.includes("safari") && !ua.includes("chrome")) { 792 groupKey = "Safari"; 793 } else if (ua.includes("edg")) { 794 groupKey = "Edge"; 795 } else if (ua.includes("curl")) { 796 groupKey = "curl"; 797 } else if (ua.includes("wget")) { 798 groupKey = "wget"; 799 } else if (ua.includes("postman")) { 800 groupKey = "Postman"; 801 } else if ( 802 ua.includes("bot") || 803 ua.includes("crawler") || 804 ua.includes("spider") 805 ) { 806 groupKey = "Bots/Crawlers"; 807 } else if (ua.includes("python")) { 808 groupKey = "Python Scripts"; 809 } else if ( 810 ua.includes("node") || 811 ua.includes("axios") || 812 ua.includes("fetch") 813 ) { 814 groupKey = "API Clients"; 815 } else { 816 groupKey = "Other"; 817 } 818 819 userAgentGroups[groupKey] = 820 (userAgentGroups[groupKey] || 0) + result.count; 821 } 822 823 // Convert back to array format, sorted by count 824 const topUserAgents = Object.entries(userAgentGroups) 825 .map(([userAgent, count]) => ({ userAgent, count })) 826 .sort((a, b) => b.count - a.count) 827 .slice(0, 10); 828 829 // Enhanced Latency Analytics 830 831 // Get all response times for percentile calculations (excluding stats) 832 const responseTimes = this.db 833 .query( 834 ` 835 SELECT response_time 836 FROM request_analytics 837 WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 838 ORDER BY response_time 839 `, 840 ) 841 .all(cutoffTime) as Array<{ response_time: number }>; 842 843 // Calculate percentiles 844 const calculatePercentile = ( 845 arr: number[], 846 percentile: number, 847 ): number | null => { 848 if (arr.length === 0) return null; 849 const index = Math.ceil((percentile / 100) * arr.length) - 1; 850 return arr[Math.max(0, index)] ?? 0; 851 }; 852 853 const sortedTimes = responseTimes 854 .map((r) => r.response_time) 855 .sort((a, b) => a - b); 856 const percentiles = { 857 p50: calculatePercentile(sortedTimes, 50), 858 p75: calculatePercentile(sortedTimes, 75), 859 p90: calculatePercentile(sortedTimes, 90), 860 p95: calculatePercentile(sortedTimes, 95), 861 p99: calculatePercentile(sortedTimes, 99), 862 }; 863 864 // Response time distribution 865 const totalWithResponseTime = responseTimes.length; 866 const distributionRanges = [ 867 { min: 0, max: 50, label: "0-50ms" }, 868 { min: 50, max: 100, label: "50-100ms" }, 869 { min: 100, max: 200, label: "100-200ms" }, 870 { min: 200, max: 500, label: "200-500ms" }, 871 { min: 500, max: 1000, label: "500ms-1s" }, 872 { min: 1000, max: 2000, label: "1-2s" }, 873 { min: 2000, max: 5000, label: "2-5s" }, 874 { min: 5000, max: Infinity, label: "5s+" }, 875 ]; 876 877 const distribution = distributionRanges.map((range) => { 878 const count = sortedTimes.filter( 879 (time) => time >= range.min && time < range.max, 880 ).length; 881 return { 882 range: range.label, 883 count, 884 percentage: 885 totalWithResponseTime > 0 ? (count / totalWithResponseTime) * 100 : 0, 886 }; 887 }); 888 889 // Slowest endpoints (grouped) 890 const slowestEndpoints = requestsByEndpoint 891 .filter((e) => e.averageResponseTime > 0) 892 .sort((a, b) => b.averageResponseTime - a.averageResponseTime) 893 .slice(0, 10); 894 895 // Latency over time - hourly for 1 day, daily for longer periods 896 let latencyOverTime: Array<{ 897 time: string; 898 averageResponseTime: number; 899 p95: number | null; 900 count: number; 901 }>; 902 903 if (days === 1) { 904 // Hourly latency data for last 24 hours (excluding stats) 905 const latencyOverTimeRaw = this.db 906 .query( 907 ` 908 SELECT 909 strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) as time, 910 AVG(response_time) as averageResponseTime, 911 COUNT(*) as count 912 FROM request_analytics 913 WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 914 GROUP BY strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) 915 ORDER BY time ASC 916 `, 917 ) 918 .all(cutoffTime) as Array<{ 919 time: string; 920 averageResponseTime: number; 921 count: number; 922 }>; 923 924 // Calculate P95 for each hour 925 latencyOverTime = latencyOverTimeRaw.map((hourData) => { 926 const hourStart = new Date(hourData.time).getTime(); 927 const hourEnd = hourStart + 60 * 60 * 1000; // 1 hour later 928 929 const hourResponseTimes = this.db 930 .query( 931 ` 932 SELECT response_time 933 FROM request_analytics 934 WHERE timestamp >= ? AND timestamp < ? AND response_time IS NOT NULL AND endpoint != '/stats' 935 ORDER BY response_time 936 `, 937 ) 938 .all(hourStart, hourEnd) as Array<{ response_time: number }>; 939 940 const hourTimes = hourResponseTimes 941 .map((r) => r.response_time) 942 .sort((a, b) => a - b); 943 const p95 = calculatePercentile(hourTimes, 95); 944 945 return { 946 time: hourData.time, 947 averageResponseTime: hourData.averageResponseTime, 948 p95, 949 count: hourData.count, 950 }; 951 }); 952 } else { 953 // Daily latency data for longer periods (excluding stats) 954 const latencyOverTimeRaw = this.db 955 .query( 956 ` 957 SELECT 958 DATE(timestamp / 1000, 'unixepoch') as time, 959 AVG(response_time) as averageResponseTime, 960 COUNT(*) as count 961 FROM request_analytics 962 WHERE timestamp > ? AND response_time IS NOT NULL AND endpoint != '/stats' 963 GROUP BY DATE(timestamp / 1000, 'unixepoch') 964 ORDER BY time ASC 965 `, 966 ) 967 .all(cutoffTime) as Array<{ 968 time: string; 969 averageResponseTime: number; 970 count: number; 971 }>; 972 973 // Calculate P95 for each day 974 latencyOverTime = latencyOverTimeRaw.map((dayData) => { 975 const dayStart = new Date(dayData.time + " 00:00:00").getTime(); 976 const dayEnd = dayStart + 24 * 60 * 60 * 1000; // 1 day later 977 978 const dayResponseTimes = this.db 979 .query( 980 ` 981 SELECT response_time 982 FROM request_analytics 983 WHERE timestamp >= ? AND timestamp < ? AND response_time IS NOT NULL AND endpoint != '/stats' 984 ORDER BY response_time 985 `, 986 ) 987 .all(dayStart, dayEnd) as Array<{ response_time: number }>; 988 989 const dayTimes = dayResponseTimes 990 .map((r) => r.response_time) 991 .sort((a, b) => a - b); 992 const p95 = calculatePercentile(dayTimes, 95); 993 994 return { 995 time: dayData.time, 996 averageResponseTime: dayData.averageResponseTime, 997 p95, 998 count: dayData.count, 999 }; 1000 }); 1001 } 1002 1003 // Performance Metrics 1004 const errorRequests = statusResults 1005 .filter((s) => s.status >= 400) 1006 .reduce((sum, s) => sum + s.count, 0); 1007 const errorRate = 1008 totalResult.count > 0 ? (errorRequests / totalResult.count) * 100 : 0; 1009 1010 // Calculate throughput (requests per hour) 1011 const timeSpanHours = days * 24; 1012 const throughput = totalResult.count / timeSpanHours; 1013 1014 // Calculate APDEX score (Application Performance Index) 1015 // Satisfied: <= 100ms, Tolerating: <= 400ms, Frustrated: > 400ms 1016 const satisfiedCount = sortedTimes.filter((t) => t <= 100).length; 1017 const toleratingCount = sortedTimes.filter( 1018 (t) => t > 100 && t <= 400, 1019 ).length; 1020 const apdex = 1021 totalWithResponseTime > 0 1022 ? (satisfiedCount + toleratingCount * 0.5) / totalWithResponseTime 1023 : 0; 1024 1025 // Calculate cache hit rate (redirects vs data endpoints) 1026 const redirectRequests = requestsByEndpoint 1027 .filter((e) => 1028 e.endpoint === "User Redirects" || 1029 e.endpoint === "Emoji Redirects") 1030 .reduce((sum, e) => sum + e.count, 0); 1031 const dataRequests = requestsByEndpoint 1032 .filter((e) => 1033 e.endpoint === "User Data" || 1034 e.endpoint === "Emoji Data") 1035 .reduce((sum, e) => sum + e.count, 0); 1036 const cachehitRate = 1037 redirectRequests + dataRequests > 0 1038 ? (redirectRequests / (redirectRequests + dataRequests)) * 100 1039 : 0; 1040 1041 // Simulate uptime (would need actual monitoring data) 1042 const uptime = Math.max(0, 100 - errorRate * 2); // Simple approximation 1043 1044 // Peak traffic analysis (excluding stats) 1045 const peakHourData = this.db 1046 .query( 1047 ` 1048 SELECT 1049 strftime('%H:00', datetime(timestamp / 1000, 'unixepoch')) as hour, 1050 COUNT(*) as count 1051 FROM request_analytics 1052 WHERE timestamp > ? AND endpoint != '/stats' 1053 GROUP BY strftime('%H:00', datetime(timestamp / 1000, 'unixepoch')) 1054 ORDER BY count DESC 1055 LIMIT 1 1056 `, 1057 ) 1058 .get(cutoffTime) as { hour: string; count: number } | null; 1059 1060 const peakDayData = this.db 1061 .query( 1062 ` 1063 SELECT 1064 DATE(timestamp / 1000, 'unixepoch') as day, 1065 COUNT(*) as count 1066 FROM request_analytics 1067 WHERE timestamp > ? AND endpoint != '/stats' 1068 GROUP BY DATE(timestamp / 1000, 'unixepoch') 1069 ORDER BY count DESC 1070 LIMIT 1 1071 `, 1072 ) 1073 .get(cutoffTime) as { day: string; count: number } | null; 1074 1075 // Traffic Overview - detailed route breakdown over time 1076 let trafficOverview: Array<{ 1077 time: string; 1078 routes: Record<string, number>; 1079 total: number; 1080 }>; 1081 1082 if (days === 1) { 1083 // Hourly route breakdown for last 24 hours 1084 const trafficRaw = this.db 1085 .query( 1086 ` 1087 SELECT 1088 strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) as time, 1089 endpoint, 1090 COUNT(*) as count 1091 FROM request_analytics 1092 WHERE timestamp > ? AND endpoint != '/stats' 1093 GROUP BY strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')), endpoint 1094 ORDER BY time ASC 1095 `, 1096 ) 1097 .all(cutoffTime) as Array<{ 1098 time: string; 1099 endpoint: string; 1100 count: number; 1101 }>; 1102 1103 // Group by time and create route breakdown 1104 const timeGroups: Record<string, Record<string, number>> = {}; 1105 for (const row of trafficRaw) { 1106 if (!timeGroups[row.time]) { 1107 timeGroups[row.time] = {}; 1108 } 1109 1110 // Apply same grouping logic as endpoints 1111 let groupKey: string; 1112 const endpoint = row.endpoint; 1113 1114 if (endpoint === "/" || endpoint === "/dashboard") { 1115 groupKey = "Dashboard"; 1116 } else if (endpoint === "/health") { 1117 groupKey = "Health Check"; 1118 } else if (endpoint === "/swagger" || endpoint.startsWith("/swagger")) { 1119 groupKey = "API Documentation"; 1120 } else if (endpoint === "/emojis") { 1121 groupKey = "Emoji List"; 1122 } else if (endpoint.match(/^\/emojis\/[^\/]+$/)) { 1123 groupKey = "Emoji Data"; 1124 } else if (endpoint.match(/^\/emojis\/[^\/]+\/r$/)) { 1125 groupKey = "Emoji Redirects"; 1126 } else if (endpoint.match(/^\/users\/[^\/]+$/)) { 1127 groupKey = "User Data"; 1128 } else if (endpoint.match(/^\/users\/[^\/]+\/r$/)) { 1129 groupKey = "User Redirects"; 1130 } else if (endpoint.match(/^\/users\/[^\/]+\/purge$/)) { 1131 groupKey = "Cache Management"; 1132 } else if (endpoint === "/reset") { 1133 groupKey = "Cache Management"; 1134 } else { 1135 // For any other endpoints, try to categorize them 1136 if (endpoint.includes("/users/") && endpoint.includes("/r")) { 1137 groupKey = "User Redirects"; 1138 } else if (endpoint.includes("/users/")) { 1139 groupKey = "User Data"; 1140 } else if (endpoint.includes("/emojis/") && endpoint.includes("/r")) { 1141 groupKey = "Emoji Redirects"; 1142 } else if (endpoint.includes("/emojis/")) { 1143 groupKey = "Emoji Data"; 1144 } else { 1145 groupKey = "Other"; 1146 } 1147 } 1148 1149 const group = timeGroups[row.time]; 1150 1151 if (group) { 1152 group[groupKey] = (group[groupKey] || 0) + row.count; 1153 } 1154 } 1155 1156 trafficOverview = Object.entries(timeGroups) 1157 .map(([time, routes]) => ({ 1158 time, 1159 routes, 1160 total: Object.values(routes).reduce((sum, count) => sum + count, 0), 1161 })) 1162 .sort((a, b) => a.time.localeCompare(b.time)); 1163 } else if (days <= 7) { 1164 // 4-hour intervals for 7 days 1165 const trafficRaw = this.db 1166 .query( 1167 ` 1168 SELECT 1169 strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')) as hour, 1170 endpoint, 1171 COUNT(*) as count 1172 FROM request_analytics 1173 WHERE timestamp > ? AND endpoint != '/stats' 1174 GROUP BY strftime('%Y-%m-%d %H:00', datetime(timestamp / 1000, 'unixepoch')), endpoint 1175 ORDER BY hour ASC 1176 `, 1177 ) 1178 .all(cutoffTime) as Array<{ 1179 hour: string; 1180 endpoint: string; 1181 count: number; 1182 }>; 1183 1184 // Group into 4-hour intervals 1185 const intervalGroups: Record<string, Record<string, number>> = {}; 1186 for (const row of trafficRaw) { 1187 const hourStr = row.hour?.split(" ")[1]?.split(":")[0]; 1188 const hour = hourStr ? parseInt(hourStr) : 0; 1189 const intervalHour = Math.floor(hour / 4) * 4; 1190 const intervalTime = 1191 row.hour.split(" ")[0] + 1192 ` ${intervalHour.toString().padStart(2, "0")}:00`; 1193 1194 if (!intervalGroups[intervalTime]) { 1195 intervalGroups[intervalTime] = {}; 1196 } 1197 1198 // Apply same grouping logic 1199 let groupKey: string; 1200 const endpoint = row.endpoint; 1201 1202 if (endpoint === "/" || endpoint === "/dashboard") { 1203 groupKey = "Dashboard"; 1204 } else if (endpoint === "/health") { 1205 groupKey = "Health Check"; 1206 } else if (endpoint === "/swagger" || endpoint.startsWith("/swagger")) { 1207 groupKey = "API Documentation"; 1208 } else if (endpoint === "/emojis") { 1209 groupKey = "Emoji List"; 1210 } else if (endpoint.match(/^\/emojis\/[^\/]+$/)) { 1211 groupKey = "Emoji Data"; 1212 } else if (endpoint.match(/^\/emojis\/[^\/]+\/r$/)) { 1213 groupKey = "Emoji Redirects"; 1214 } else if (endpoint.match(/^\/users\/[^\/]+$/)) { 1215 groupKey = "User Data"; 1216 } else if (endpoint.match(/^\/users\/[^\/]+\/r$/)) { 1217 groupKey = "User Redirects"; 1218 } else if (endpoint.match(/^\/users\/[^\/]+\/purge$/)) { 1219 groupKey = "Cache Management"; 1220 } else if (endpoint === "/reset") { 1221 groupKey = "Cache Management"; 1222 } else { 1223 // For any other endpoints, try to categorize them 1224 if (endpoint.includes("/users/") && endpoint.includes("/r")) { 1225 groupKey = "User Redirects"; 1226 } else if (endpoint.includes("/users/")) { 1227 groupKey = "User Data"; 1228 } else if (endpoint.includes("/emojis/") && endpoint.includes("/r")) { 1229 groupKey = "Emoji Redirects"; 1230 } else if (endpoint.includes("/emojis/")) { 1231 groupKey = "Emoji Data"; 1232 } else { 1233 groupKey = "Other"; 1234 } 1235 } 1236 1237 intervalGroups[intervalTime][groupKey] = 1238 (intervalGroups[intervalTime][groupKey] || 0) + row.count; 1239 } 1240 1241 trafficOverview = Object.entries(intervalGroups) 1242 .map(([time, routes]) => ({ 1243 time, 1244 routes, 1245 total: Object.values(routes).reduce((sum, count) => sum + count, 0), 1246 })) 1247 .sort((a, b) => a.time.localeCompare(b.time)); 1248 } else { 1249 // Daily breakdown for longer periods 1250 const trafficRaw = this.db 1251 .query( 1252 ` 1253 SELECT 1254 DATE(timestamp / 1000, 'unixepoch') as time, 1255 endpoint, 1256 COUNT(*) as count 1257 FROM request_analytics 1258 WHERE timestamp > ? AND endpoint != '/stats' 1259 GROUP BY DATE(timestamp / 1000, 'unixepoch'), endpoint 1260 ORDER BY time ASC 1261 `, 1262 ) 1263 .all(cutoffTime) as Array<{ 1264 time: string; 1265 endpoint: string; 1266 count: number; 1267 }>; 1268 1269 // Group by day 1270 const dayGroups: Record<string, Record<string, number>> = {}; 1271 for (const row of trafficRaw) { 1272 if (!dayGroups[row.time]) { 1273 dayGroups[row.time] = {}; 1274 } 1275 1276 // Apply same grouping logic 1277 let groupKey: string; 1278 const endpoint = row.endpoint; 1279 1280 if (endpoint === "/" || endpoint === "/dashboard") { 1281 groupKey = "Dashboard"; 1282 } else if (endpoint === "/health") { 1283 groupKey = "Health Check"; 1284 } else if (endpoint === "/swagger" || endpoint.startsWith("/swagger")) { 1285 groupKey = "API Documentation"; 1286 } else if (endpoint === "/emojis") { 1287 groupKey = "Emoji List"; 1288 } else if (endpoint.match(/^\/emojis\/[^\/]+$/)) { 1289 groupKey = "Emoji Data"; 1290 } else if (endpoint.match(/^\/emojis\/[^\/]+\/r$/)) { 1291 groupKey = "Emoji Redirects"; 1292 } else if (endpoint.match(/^\/users\/[^\/]+$/)) { 1293 groupKey = "User Data"; 1294 } else if (endpoint.match(/^\/users\/[^\/]+\/r$/)) { 1295 groupKey = "User Redirects"; 1296 } else if (endpoint.match(/^\/users\/[^\/]+\/purge$/)) { 1297 groupKey = "Cache Management"; 1298 } else if (endpoint === "/reset") { 1299 groupKey = "Cache Management"; 1300 } else { 1301 // For any other endpoints, try to categorize them 1302 if (endpoint.includes("/users/") && endpoint.includes("/r")) { 1303 groupKey = "User Redirects"; 1304 } else if (endpoint.includes("/users/")) { 1305 groupKey = "User Data"; 1306 } else if (endpoint.includes("/emojis/") && endpoint.includes("/r")) { 1307 groupKey = "Emoji Redirects"; 1308 } else if (endpoint.includes("/emojis/")) { 1309 groupKey = "Emoji Data"; 1310 } else { 1311 groupKey = "Other"; 1312 } 1313 } 1314 const group = dayGroups[row.time]; 1315 if (group) { 1316 group[groupKey] = (group[groupKey] || 0) + row.count; 1317 } 1318 } 1319 1320 trafficOverview = Object.entries(dayGroups) 1321 .map(([time, routes]) => ({ 1322 time, 1323 routes, 1324 total: Object.values(routes).reduce((sum, count) => sum + count, 0), 1325 })) 1326 .sort((a, b) => a.time.localeCompare(b.time)); 1327 } 1328 1329 return { 1330 totalRequests: totalResult.count, 1331 requestsByEndpoint: requestsByEndpoint, 1332 requestsByStatus: statusResults, 1333 requestsByDay: timeResults, 1334 averageResponseTime: avgResponseResult.avg, 1335 topUserAgents: topUserAgents, 1336 latencyAnalytics: { 1337 percentiles, 1338 distribution, 1339 slowestEndpoints, 1340 latencyOverTime, 1341 }, 1342 performanceMetrics: { 1343 uptime, 1344 errorRate, 1345 throughput, 1346 apdex, 1347 cachehitRate, 1348 }, 1349 peakTraffic: { 1350 peakHour: peakHourData?.hour || "N/A", 1351 peakRequests: peakHourData?.count || 0, 1352 peakDay: peakDayData?.day || "N/A", 1353 peakDayRequests: peakDayData?.count || 0, 1354 }, 1355 dashboardMetrics: { 1356 statsRequests: statsResult.count, 1357 totalWithStats: totalResult.count + statsResult.count, 1358 }, 1359 trafficOverview, 1360 }; 1361 } 1362} 1363 1364export { Cache as SlackCache };