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