/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see .
*/
import type {ErrorContext, ErrorType, HonoEnv} from '@fluxer/media_proxy/src/types/HonoEnv';
import type {MetricsInterface} from '@fluxer/media_proxy/src/types/Metrics';
import {createMiddleware} from 'hono/factory';
function getRouteFromPath(path: string): string | null {
if (path === '/_health' || path === '/internal/telemetry') return null;
if (path.startsWith('/avatars/')) return 'avatars';
if (path.startsWith('/icons/')) return 'icons';
if (path.startsWith('/banners/')) return 'banners';
if (path.startsWith('/emojis/')) return 'emojis';
if (path.startsWith('/stickers/')) return 'stickers';
if (path.startsWith('/attachments/')) return 'attachments';
if (path.startsWith('/external/')) return 'external';
if (path.startsWith('/guilds/')) return 'guild_assets';
return 'other';
}
function getErrorTypeFromStatus(status: number): ErrorType {
switch (status) {
case 400:
return 'bad_request';
case 401:
return 'unauthorized';
case 403:
return 'forbidden';
case 404:
return 'not_found';
case 408:
return 'timeout';
case 413:
return 'payload_too_large';
default:
if (status >= 500 && status < 600) {
return 'upstream_5xx';
}
return 'other';
}
}
export function createMetricsMiddleware(metrics: MetricsInterface) {
return createMiddleware(async (ctx, next) => {
const start = Date.now();
let errorType: ErrorType | undefined;
let errorSource: string | undefined;
try {
await next();
} catch (error) {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('timeout') || message.includes('timed out') || message.includes('etimedout')) {
errorType = 'timeout';
errorSource = 'network';
} else if (
message.includes('econnrefused') ||
message.includes('econnreset') ||
message.includes('enotfound')
) {
errorType = 'upstream_5xx';
errorSource = 'network';
}
}
throw error;
} finally {
const duration = Date.now() - start;
const route = getRouteFromPath(ctx.req.path);
if (route !== null) {
const status = ctx.res.status;
const baseDimensions = {
'http.request.method': ctx.req.method,
'url.path': route,
'http.response.status_code': String(status),
};
metrics.histogram({
name: 'http.server.request.duration',
dimensions: baseDimensions,
valueMs: duration,
});
metrics.counter({
name: 'http.server.request.count',
dimensions: baseDimensions,
value: 1,
});
metrics.histogram({
name: 'media_proxy.latency',
dimensions: {route},
valueMs: duration,
});
metrics.counter({
name: 'media_proxy.request',
dimensions: {route, status: String(status)},
});
if (status >= 400) {
const errorContext = ctx.get('metricsErrorContext') as ErrorContext | undefined;
const finalErrorType = errorContext?.errorType ?? errorType ?? getErrorTypeFromStatus(status);
const finalErrorSource = errorContext?.errorSource ?? errorSource ?? 'handler';
metrics.counter({
name: 'media_proxy.failure',
dimensions: {
route,
status: String(status),
error_type: finalErrorType,
error_source: finalErrorSource,
},
});
} else {
metrics.counter({
name: 'media_proxy.success',
dimensions: {route, status: String(status)},
});
}
const contentLength = ctx.res.headers.get('content-length');
if (contentLength) {
const bytes = Number.parseInt(contentLength, 10);
if (!Number.isNaN(bytes)) {
metrics.counter({
name: 'media_proxy.bytes',
dimensions: {route},
value: bytes,
});
}
}
}
}
});
}