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