From e5a8afab2fbde90e4c1d4d1f42102a3021b65d83 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Wed, 12 Mar 2025 20:21:10 -0600 Subject: [PATCH] add metrics hopefully this works --- .metrics.json | 604 ++++++++++++++++++++++++ nitro.config.ts | 2 +- package-lock.json | 40 +- package.json | 1 + server/middleware/metrics.ts | 59 +++ server/routes/metrics.ts | 34 ++ server/routes/metrics/captcha.post.ts | 41 ++ server/routes/metrics/providers.post.ts | 60 +++ server/utils/logger.ts | 38 ++ server/utils/metrics.ts | 306 ++++++++++++ 10 files changed, 1183 insertions(+), 2 deletions(-) create mode 100644 .metrics.json create mode 100644 server/middleware/metrics.ts create mode 100644 server/routes/metrics.ts create mode 100644 server/routes/metrics/captcha.post.ts create mode 100644 server/routes/metrics/providers.post.ts create mode 100644 server/utils/logger.ts create mode 100644 server/utils/metrics.ts diff --git a/.metrics.json b/.metrics.json new file mode 100644 index 0000000..a3866e9 --- /dev/null +++ b/.metrics.json @@ -0,0 +1,604 @@ +[ + { + "help": "Number of users by namespace", + "name": "mw_user_count", + "type": "counter", + "values": [ + { + "value": 3, + "labels": { + "namespace": "movie-web" + } + } + ], + "aggregator": "sum" + }, + { + "help": "Number of captcha solves by success status", + "name": "mw_captcha_solves", + "type": "counter", + "values": [], + "aggregator": "sum" + }, + { + "help": "Number of requests by provider hostname", + "name": "mw_provider_hostname_count", + "type": "counter", + "values": [ + { + "value": 9, + "labels": { + "hostname": "" + } + } + ], + "aggregator": "sum" + }, + { + "help": "Number of provider requests by status", + "name": "mw_provider_status_count", + "type": "counter", + "values": [ + { + "value": 23, + "labels": { + "provider_id": "test", + "status": "success" + } + }, + { + "value": 1, + "labels": { + "provider_id": "test2", + "status": "failure" + } + }, + { + "value": 2, + "labels": { + "provider_id": "uiralive", + "status": "success" + } + }, + { + "value": 1, + "labels": { + "provider_id": "uiralive", + "status": "failure" + } + } + ], + "aggregator": "sum" + }, + { + "help": "Number of media watch events", + "name": "mw_media_watch_count", + "type": "counter", + "values": [ + { + "value": 2, + "labels": { + "tmdb_full_id": "movie-132", + "provider_id": "test", + "title": "Test Movie 132", + "success": "true" + } + }, + { + "value": 3, + "labels": { + "tmdb_full_id": "movie-123", + "provider_id": "test", + "title": "Test Movie", + "success": "true" + } + }, + { + "value": 1, + "labels": { + "tmdb_full_id": "show-11123", + "provider_id": "test2", + "title": "Test Movie boobs", + "success": "false" + } + }, + { + "value": 2, + "labels": { + "tmdb_full_id": "show-61222", + "provider_id": "uiralive", + "title": "BoJack Horseman", + "success": "true" + } + }, + { + "value": 1, + "labels": { + "tmdb_full_id": "show-61222", + "provider_id": "uiralive", + "title": "BoJack Horseman", + "success": "false" + } + } + ], + "aggregator": "sum" + }, + { + "help": "Number of provider tool usages", + "name": "mw_provider_tool_count", + "type": "counter", + "values": [ + { + "value": 2, + "labels": { + "tool": "extension" + } + }, + { + "value": 1, + "labels": { + "tool": "custom-proxy" + } + } + ], + "aggregator": "sum" + }, + { + "name": "http_request_duration_seconds", + "help": "request duration in seconds", + "type": "histogram", + "values": [ + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.005, + "method": "GET", + "route": "/favicon.ico", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.01, + "method": "GET", + "route": "/favicon.ico", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.025, + "method": "GET", + "route": "/favicon.ico", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.05, + "method": "GET", + "route": "/favicon.ico", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.1, + "method": "GET", + "route": "/favicon.ico", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.25, + "method": "GET", + "route": "/favicon.ico", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.5, + "method": "GET", + "route": "/favicon.ico", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 1, + "method": "GET", + "route": "/favicon.ico", + "status_code": "200" + } + }, + { + "value": 1, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 2.5, + "method": "GET", + "route": "/favicon.ico", + "status_code": "200" + } + }, + { + "value": 1, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 5, + "method": "GET", + "route": "/favicon.ico", + "status_code": "200" + } + }, + { + "value": 1, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 10, + "method": "GET", + "route": "/favicon.ico", + "status_code": "200" + } + }, + { + "value": 2, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": "+Inf", + "method": "GET", + "route": "/favicon.ico", + "status_code": "200" + } + }, + { + "value": 16.000009167, + "metricName": "http_request_duration_seconds_sum", + "labels": { + "method": "GET", + "route": "/favicon.ico", + "status_code": "200" + } + }, + { + "value": 2, + "metricName": "http_request_duration_seconds_count", + "labels": { + "method": "GET", + "route": "/favicon.ico", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.005, + "method": "POST", + "route": "/metrics/providers", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.01, + "method": "POST", + "route": "/metrics/providers", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.025, + "method": "POST", + "route": "/metrics/providers", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.05, + "method": "POST", + "route": "/metrics/providers", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.1, + "method": "POST", + "route": "/metrics/providers", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.25, + "method": "POST", + "route": "/metrics/providers", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.5, + "method": "POST", + "route": "/metrics/providers", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 1, + "method": "POST", + "route": "/metrics/providers", + "status_code": "200" + } + }, + { + "value": 1, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 2.5, + "method": "POST", + "route": "/metrics/providers", + "status_code": "200" + } + }, + { + "value": 1, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 5, + "method": "POST", + "route": "/metrics/providers", + "status_code": "200" + } + }, + { + "value": 1, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 10, + "method": "POST", + "route": "/metrics/providers", + "status_code": "200" + } + }, + { + "value": 2, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": "+Inf", + "method": "POST", + "route": "/metrics/providers", + "status_code": "200" + } + }, + { + "value": 16.000032751, + "metricName": "http_request_duration_seconds_sum", + "labels": { + "method": "POST", + "route": "/metrics/providers", + "status_code": "200" + } + }, + { + "value": 2, + "metricName": "http_request_duration_seconds_count", + "labels": { + "method": "POST", + "route": "/metrics/providers", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.005, + "method": "GET", + "route": "/", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.01, + "method": "GET", + "route": "/", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.025, + "method": "GET", + "route": "/", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.05, + "method": "GET", + "route": "/", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.1, + "method": "GET", + "route": "/", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.25, + "method": "GET", + "route": "/", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 0.5, + "method": "GET", + "route": "/", + "status_code": "200" + } + }, + { + "value": 0, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 1, + "method": "GET", + "route": "/", + "status_code": "200" + } + }, + { + "value": 2, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 2.5, + "method": "GET", + "route": "/", + "status_code": "200" + } + }, + { + "value": 2, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 5, + "method": "GET", + "route": "/", + "status_code": "200" + } + }, + { + "value": 2, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": 10, + "method": "GET", + "route": "/", + "status_code": "200" + } + }, + { + "value": 2, + "metricName": "http_request_duration_seconds_bucket", + "exemplar": null, + "labels": { + "le": "+Inf", + "method": "GET", + "route": "/", + "status_code": "200" + } + }, + { + "value": 3.000003208, + "metricName": "http_request_duration_seconds_sum", + "labels": { + "method": "GET", + "route": "/", + "status_code": "200" + } + }, + { + "value": 2, + "metricName": "http_request_duration_seconds_count", + "labels": { + "method": "GET", + "route": "/", + "status_code": "200" + } + } + ], + "aggregator": "sum" + } +] \ No newline at end of file diff --git a/nitro.config.ts b/nitro.config.ts index 33974a8..22c7e9c 100644 --- a/nitro.config.ts +++ b/nitro.config.ts @@ -14,7 +14,7 @@ export default defineNitroConfig({ name: process.env.META_NAME || '', description: process.env.META_DESCRIPTION || '', version: version || '', - captcha: process.env.CAPTCHA || false, + captcha: (process.env.CAPTCHA === 'true').toString(), captchaClientKey: process.env.CAPTCHA_CLIENT_KEY || '' } }, diff --git a/package-lock.json b/package-lock.json index 105aa8a..71ccecf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "open-backend-1", + "name": "open-backend", "lockfileVersion": 3, "requires": true, "packages": { @@ -9,6 +9,7 @@ "bs58": "^6.0.0", "dotenv": "^16.4.7", "jsonwebtoken": "^9.0.2", + "prom-client": "^15.1.3", "tmdb-ts": "^2.0.1", "tweetnacl": "^1.0.3", "zod": "^3.24.2" @@ -733,6 +734,15 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -2052,6 +2062,12 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -4574,6 +4590,19 @@ "dev": true, "license": "MIT" }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/quansync": { "version": "0.2.8", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.8.tgz", @@ -5397,6 +5426,15 @@ "streamx": "^2.15.0" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/terser": { "version": "5.39.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", diff --git a/package.json b/package.json index 59e5f89..131007d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "bs58": "^6.0.0", "dotenv": "^16.4.7", "jsonwebtoken": "^9.0.2", + "prom-client": "^15.1.3", "tmdb-ts": "^2.0.1", "tweetnacl": "^1.0.3", "zod": "^3.24.2" diff --git a/server/middleware/metrics.ts b/server/middleware/metrics.ts new file mode 100644 index 0000000..f5c7ae6 --- /dev/null +++ b/server/middleware/metrics.ts @@ -0,0 +1,59 @@ +import { recordHttpRequest } from '~/utils/metrics'; +import { scopedLogger } from '~/utils/logger'; + +const log = scopedLogger('metrics-middleware'); + +// Paths we don't want to track metrics for +const EXCLUDED_PATHS = [ + '/metrics', + '/ping.txt', + '/favicon.ico', + '/robots.txt', + '/sitemap.xml' +]; + +export default defineEventHandler(async (event) => { + // Skip tracking excluded paths + if (EXCLUDED_PATHS.includes(event.path)) { + return; + } + + const start = process.hrtime(); + + try { + // Wait for the request to complete + await event._handled; + } finally { + // Calculate duration once the response is sent + const [seconds, nanoseconds] = process.hrtime(start); + const duration = seconds + nanoseconds / 1e9; + + // Get cleaned route path (remove dynamic segments) + const method = event.method; + const route = getCleanPath(event.path); + const statusCode = event.node.res.statusCode || 200; + + // Record the request metrics + recordHttpRequest(method, route, statusCode, duration); + + log.debug('Recorded HTTP request metrics', { + evt: 'http_metrics', + method, + route, + statusCode, + duration + }); + } +}); + +// Helper to normalize routes with dynamic segments (e.g., /users/123 -> /users/:id) +function getCleanPath(path: string): string { + // Common patterns for Nitro routes + return path + .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, '/:uuid') + .replace(/\/\d+/g, '/:id') + .replace(/@me/, ':uid') + .replace(/\/[^\/]+\/progress\/[^\/]+/, '/:uid/progress/:tmdbid') + .replace(/\/[^\/]+\/bookmarks\/[^\/]+/, '/:uid/bookmarks/:tmdbid') + .replace(/\/sessions\/[^\/]+/, '/sessions/:sid'); +} \ No newline at end of file diff --git a/server/routes/metrics.ts b/server/routes/metrics.ts new file mode 100644 index 0000000..6a302ab --- /dev/null +++ b/server/routes/metrics.ts @@ -0,0 +1,34 @@ +import { register } from 'prom-client'; +import { setupMetrics } from '../utils/metrics'; +import { scopedLogger } from '../utils/logger'; + +const log = scopedLogger('metrics-endpoint'); + +let isInitialized = false; + +async function ensureMetricsInitialized() { + if (!isInitialized) { + log.info('Initializing metrics from endpoint...', { evt: 'init_start' }); + await setupMetrics(); + isInitialized = true; + log.info('Metrics initialized from endpoint', { evt: 'init_complete' }); + } +} + +export default defineEventHandler(async (event) => { + try { + await ensureMetricsInitialized(); + const metrics = await register.metrics(); + event.node.res.setHeader('Content-Type', register.contentType); + return metrics; + } catch (error) { + log.error('Error in metrics endpoint:', { + evt: 'metrics_error', + error: error instanceof Error ? error.message : String(error) + }); + throw createError({ + statusCode: 500, + message: error instanceof Error ? error.message : 'Failed to collect metrics' + }); + } +}); \ No newline at end of file diff --git a/server/routes/metrics/captcha.post.ts b/server/routes/metrics/captcha.post.ts new file mode 100644 index 0000000..4241b37 --- /dev/null +++ b/server/routes/metrics/captcha.post.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { getMetrics, recordCaptchaMetrics } from '~/utils/metrics'; +import { scopedLogger } from '~/utils/logger'; +import { setupMetrics } from '~/utils/metrics'; + +const log = scopedLogger('metrics-captcha'); + +let isInitialized = false; + +async function ensureMetricsInitialized() { + if (!isInitialized) { + log.info('Initializing metrics from captcha endpoint...', { evt: 'init_start' }); + await setupMetrics(); + isInitialized = true; + log.info('Metrics initialized from captcha endpoint', { evt: 'init_complete' }); + } +} + +export default defineEventHandler(async (event) => { + try { + await ensureMetricsInitialized(); + + const body = await readBody(event); + const validatedBody = z.object({ + success: z.boolean(), + }).parse(body); + + recordCaptchaMetrics(validatedBody.success); + + return true; + } catch (error) { + log.error('Failed to process captcha metrics', { + evt: 'metrics_error', + error: error instanceof Error ? error.message : String(error) + }); + throw createError({ + statusCode: error instanceof Error && error.message === 'metrics not initialized' ? 503 : 400, + message: error instanceof Error ? error.message : 'Failed to process metrics' + }); + } +}); \ No newline at end of file diff --git a/server/routes/metrics/providers.post.ts b/server/routes/metrics/providers.post.ts new file mode 100644 index 0000000..cacc2d0 --- /dev/null +++ b/server/routes/metrics/providers.post.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; +import { getMetrics, recordProviderMetrics } from '~/utils/metrics'; +import { scopedLogger } from '~/utils/logger'; +import { setupMetrics } from '~/utils/metrics'; + +const log = scopedLogger('metrics-providers'); + +let isInitialized = false; + +async function ensureMetricsInitialized() { + if (!isInitialized) { + log.info('Initializing metrics from providers endpoint...', { evt: 'init_start' }); + await setupMetrics(); + isInitialized = true; + log.info('Metrics initialized from providers endpoint', { evt: 'init_complete' }); + } +} + +const metricsProviderSchema = z.object({ + tmdbId: z.string(), + type: z.string(), + title: z.string(), + seasonId: z.string().optional(), + episodeId: z.string().optional(), + status: z.string(), + providerId: z.string(), + embedId: z.string().optional(), + errorMessage: z.string().optional(), + fullError: z.string().optional(), +}); + +const metricsProviderInputSchema = z.object({ + items: z.array(metricsProviderSchema).max(10).min(1), + tool: z.string().optional(), +}); + +export default defineEventHandler(async (event) => { + try { + await ensureMetricsInitialized(); + + const body = await readBody(event); + const validatedBody = metricsProviderInputSchema.parse(body); + + const hostname = event.node.req.headers.origin?.slice(0, 255) ?? ''; + + // Use the simplified recordProviderMetrics function to handle all metrics recording + recordProviderMetrics(validatedBody.items, hostname, validatedBody.tool); + + return true; + } catch (error) { + log.error('Failed to process metrics', { + evt: 'metrics_error', + error: error instanceof Error ? error.message : String(error) + }); + throw createError({ + statusCode: error instanceof Error && error.message === 'metrics not initialized' ? 503 : 400, + message: error instanceof Error ? error.message : 'Failed to process metrics' + }); + } +}); \ No newline at end of file diff --git a/server/utils/logger.ts b/server/utils/logger.ts new file mode 100644 index 0000000..eaf555e --- /dev/null +++ b/server/utils/logger.ts @@ -0,0 +1,38 @@ +type LogLevel = 'info' | 'warn' | 'error' | 'debug'; + +interface LogContext { + evt?: string; + [key: string]: any; +} + +interface Logger { + info(message: string, context?: LogContext): void; + warn(message: string, context?: LogContext): void; + error(message: string, context?: LogContext): void; + debug(message: string, context?: LogContext): void; +} + +function createLogger(scope: string): Logger { + const log = (level: LogLevel, message: string, context?: LogContext) => { + const timestamp = new Date().toISOString(); + const logData = { + timestamp, + level, + scope, + message, + ...context, + }; + console.log(JSON.stringify(logData)); + }; + + return { + info: (message: string, context?: LogContext) => log('info', message, context), + warn: (message: string, context?: LogContext) => log('warn', message, context), + error: (message: string, context?: LogContext) => log('error', message, context), + debug: (message: string, context?: LogContext) => log('debug', message, context), + }; +} + +export function scopedLogger(scope: string): Logger { + return createLogger(scope); +} \ No newline at end of file diff --git a/server/utils/metrics.ts b/server/utils/metrics.ts new file mode 100644 index 0000000..d498bce --- /dev/null +++ b/server/utils/metrics.ts @@ -0,0 +1,306 @@ +import { Counter, register, collectDefaultMetrics, Histogram, Summary } from 'prom-client'; +import { prisma } from './prisma'; +import { scopedLogger } from '~/utils/logger'; +import fs from 'fs'; +import path from 'path'; + +const log = scopedLogger('metrics'); +const METRICS_FILE = '.metrics.json'; + +export type Metrics = { + user: Counter<'namespace'>; + captchaSolves: Counter<'success'>; + providerHostnames: Counter<'hostname'>; + providerStatuses: Counter<'provider_id' | 'status'>; + watchMetrics: Counter<'title' | 'tmdb_full_id' | 'provider_id' | 'success'>; + toolMetrics: Counter<'tool'>; + httpRequestDuration: Histogram<'method' | 'route' | 'status_code'>; + httpRequestSummary: Summary<'method' | 'route' | 'status_code'>; +}; + +let metrics: null | Metrics = null; + +export function getMetrics() { + if (!metrics) throw new Error('metrics not initialized'); + return metrics; +} + +async function createMetrics(): Promise { + const newMetrics = { + user: new Counter({ + name: 'mw_user_count', + help: 'Number of users by namespace', + labelNames: ['namespace'], + }), + captchaSolves: new Counter({ + name: 'mw_captcha_solves', + help: 'Number of captcha solves by success status', + labelNames: ['success'], + }), + providerHostnames: new Counter({ + name: 'mw_provider_hostname_count', + help: 'Number of requests by provider hostname', + labelNames: ['hostname'], + }), + providerStatuses: new Counter({ + name: 'mw_provider_status_count', + help: 'Number of provider requests by status', + labelNames: ['provider_id', 'status'], + }), + watchMetrics: new Counter({ + name: 'mw_media_watch_count', + help: 'Number of media watch events', + labelNames: ['title', 'tmdb_full_id', 'provider_id', 'success'], + }), + toolMetrics: new Counter({ + name: 'mw_provider_tool_count', + help: 'Number of provider tool usages', + labelNames: ['tool'], + }), + httpRequestDuration: new Histogram({ + name: 'http_request_duration_seconds', + help: 'request duration in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + }), + httpRequestSummary: new Summary({ + name: 'http_request_summary_seconds', + help: 'request duration in seconds summary', + labelNames: ['method', 'route', 'status_code'], + percentiles: [0.01, 0.05, 0.5, 0.9, 0.95, 0.99, 0.999], + }), + }; + + // Register all metrics with the Prometheus registry + register.registerMetric(newMetrics.user); + register.registerMetric(newMetrics.captchaSolves); + register.registerMetric(newMetrics.providerHostnames); + register.registerMetric(newMetrics.providerStatuses); + register.registerMetric(newMetrics.watchMetrics); + register.registerMetric(newMetrics.toolMetrics); + register.registerMetric(newMetrics.httpRequestDuration); + register.registerMetric(newMetrics.httpRequestSummary); + + return newMetrics; +} + +async function saveMetricsToFile() { + try { + if (!metrics) return; + + const metricsData = await register.getMetricsAsJSON(); + const relevantMetrics = metricsData.filter(metric => + metric.name.startsWith('mw_') || + metric.name === 'http_request_duration_seconds' + ); + + fs.writeFileSync( + METRICS_FILE, + JSON.stringify(relevantMetrics, null, 2) + ); + + log.info('Metrics saved to file', { evt: 'metrics_saved' }); + } catch (error) { + log.error('Failed to save metrics', { + evt: 'save_metrics_error', + error: error instanceof Error ? error.message : String(error) + }); + } +} + +async function loadMetricsFromFile(): Promise { + try { + if (!fs.existsSync(METRICS_FILE)) { + log.info('No saved metrics found', { evt: 'no_saved_metrics' }); + return []; + } + + const data = fs.readFileSync(METRICS_FILE, 'utf8'); + const savedMetrics = JSON.parse(data); + log.info('Loaded saved metrics', { + evt: 'metrics_loaded', + count: savedMetrics.length + }); + return savedMetrics; + } catch (error) { + log.error('Failed to load metrics', { + evt: 'load_metrics_error', + error: error instanceof Error ? error.message : String(error) + }); + return []; + } +} + +// Periodically save metrics +const SAVE_INTERVAL = 60000; // Save every minute +setInterval(saveMetricsToFile, SAVE_INTERVAL); + +// Save metrics on process exit +process.on('SIGTERM', async () => { + log.info('Saving metrics before exit...', { evt: 'exit_save' }); + await saveMetricsToFile(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + log.info('Saving metrics before exit...', { evt: 'exit_save' }); + await saveMetricsToFile(); + process.exit(0); +}); + +export async function setupMetrics() { + try { + log.info('Setting up metrics...', { evt: 'start' }); + + // Clear all existing metrics + log.info('Clearing metrics registry...', { evt: 'clear' }); + register.clear(); + + // Enable default Node.js metrics collection with appropriate settings + collectDefaultMetrics({ + register, + prefix: '', // No prefix to match the example output + eventLoopMonitoringPrecision: 1, // Ensure eventloop metrics are collected + gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5], // Match the example buckets + }); + + // Create new metrics instance + metrics = await createMetrics(); + log.info('Created new metrics...', { evt: 'created' }); + + // Load saved metrics + const savedMetrics = await loadMetricsFromFile(); + if (savedMetrics.length > 0) { + log.info('Restoring saved metrics...', { evt: 'restore_metrics' }); + savedMetrics.forEach((metric) => { + if (metric.values) { + metric.values.forEach((value) => { + switch (metric.name) { + case 'mw_user_count': + metrics?.user.inc(value.labels, value.value); + break; + case 'mw_captcha_solves': + metrics?.captchaSolves.inc(value.labels, value.value); + break; + case 'mw_provider_hostname_count': + metrics?.providerHostnames.inc(value.labels, value.value); + break; + case 'mw_provider_status_count': + metrics?.providerStatuses.inc(value.labels, value.value); + break; + case 'mw_media_watch_count': + metrics?.watchMetrics.inc(value.labels, value.value); + break; + case 'mw_provider_tool_count': + metrics?.toolMetrics.inc(value.labels, value.value); + break; + case 'http_request_duration_seconds': + // For histograms, special handling for sum and count + if (value.metricName === 'http_request_duration_seconds_sum' || + value.metricName === 'http_request_duration_seconds_count') { + metrics?.httpRequestDuration.observe(value.labels, value.value); + } + break; + } + }); + } + }); + } + + // Initialize metrics with current data + log.info('Syncing up metrics...', { evt: 'sync' }); + await updateMetrics(); + log.info('Metrics initialized!', { evt: 'end' }); + + // Save initial state + await saveMetricsToFile(); + } catch (error) { + log.error('Failed to setup metrics', { + evt: 'setup_error', + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +async function updateMetrics() { + try { + log.info('Fetching users from database...', { evt: 'update_metrics_start' }); + + const users = await prisma.users.groupBy({ + by: ['namespace'], + _count: true, + }); + + log.info('Found users', { evt: 'users_found', count: users.length }); + + metrics?.user.reset(); + log.info('Reset user metrics counter', { evt: 'metrics_reset' }); + + users.forEach((v) => { + log.info('Incrementing user metric', { + evt: 'increment_metric', + namespace: v.namespace, + count: v._count + }); + metrics?.user.inc({ namespace: v.namespace }, v._count); + }); + + log.info('Successfully updated metrics', { evt: 'update_metrics_complete' }); + } catch (error) { + log.error('Failed to update metrics', { + evt: 'update_metrics_error', + error: error instanceof Error ? error.message : String(error) + }); + throw error; + } +} + +// Export function to record HTTP request duration +export function recordHttpRequest(method: string, route: string, statusCode: number, duration: number) { + if (!metrics) return; + + const labels = { + method, + route, + status_code: statusCode.toString() + }; + + // Record in both histogram and summary + metrics.httpRequestDuration.observe(labels, duration); + metrics.httpRequestSummary.observe(labels, duration); +} + +// Functions to match previous backend API +export function recordProviderMetrics(items: any[], hostname: string, tool?: string) { + if (!metrics) return; + + // Record hostname once per request + metrics.providerHostnames.inc({ hostname }); + + // Record status and watch metrics for each item + items.forEach((item) => { + // Record provider status + metrics.providerStatuses.inc({ + provider_id: item.embedId ?? item.providerId, + status: item.status, + }); + + // Record watch metrics for each item + metrics.watchMetrics.inc({ + tmdb_full_id: item.type + '-' + item.tmdbId, + provider_id: item.providerId, + title: item.title, + success: (item.status === 'success').toString(), + }); + }); + + // Record tool metrics + if (tool) { + metrics.toolMetrics.inc({ tool }); + } +} + +export function recordCaptchaMetrics(success: boolean) { + metrics?.captchaSolves.inc({ success: success.toString() }); +} \ No newline at end of file