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