add metrics

hopefully this works
This commit is contained in:
Pas 2025-03-12 20:21:10 -06:00
parent 180fac4164
commit e5a8afab2f
10 changed files with 1183 additions and 2 deletions

604
.metrics.json Normal file
View file

@ -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": "<UNKNOWN>"
}
}
],
"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"
}
]

View file

@ -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 || ''
}
},

40
package-lock.json generated
View file

@ -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",

View file

@ -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"

View file

@ -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');
}

34
server/routes/metrics.ts Normal file
View file

@ -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'
});
}
});

View file

@ -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'
});
}
});

View file

@ -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) ?? '<UNKNOWN>';
// 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'
});
}
});

38
server/utils/logger.ts Normal file
View file

@ -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);
}

306
server/utils/metrics.ts Normal file
View file

@ -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<Metrics> {
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<any[]> {
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() });
}