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