Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 37 additions & 12 deletions server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Glob } from 'bun';
import { join } from 'node:path';
import { metricsResponse, httpRequestsTotal, httpRequestDurationSeconds } from './src/server/metrics';
import {
metricsResponse,
httpRequestsTotal,
httpRequestDurationSeconds,
normalizeMetricsPath,
} from './src/server/metrics';

const CLIENT_DIR = join(import.meta.dir, 'dist', 'client');
const SERVER_ENTRY = new URL('./dist/server/server.js', import.meta.url);
Expand All @@ -14,7 +19,10 @@ const maxAge = Number.isNaN(rawMaxAge) ? ONE_DAY * 2 : rawMaxAge;
const sMaxAge = Number.isNaN(rawSMaxAge) ? ONE_DAY : rawSMaxAge;

const NO_CACHE: Record<string, string> = {
'Cache-Control': env.ADMIN_PANEL_INDEX_CACHE_CONTROL ?? env.INDEX_CACHE_CONTROL ?? 'no-cache, no-store, must-revalidate',
'Cache-Control':
env.ADMIN_PANEL_INDEX_CACHE_CONTROL ??
env.INDEX_CACHE_CONTROL ??
'no-cache, no-store, must-revalidate',
Pragma: env.ADMIN_PANEL_INDEX_PRAGMA ?? env.INDEX_PRAGMA ?? 'no-cache',
Expires: env.ADMIN_PANEL_INDEX_EXPIRES ?? env.INDEX_EXPIRES ?? '0',
};
Expand All @@ -36,13 +44,32 @@ type Handler = { default: { fetch: (req: Request) => Promise<Response> } };

const { default: handler } = (await import(SERVER_ENTRY.href)) as Handler;

async function buildStaticRoutes(): Promise<Record<string, () => Response>> {
const routes: Record<string, () => Response> = {};
async function withHttpMetrics(
req: Request,
pathname: string,
getResponse: () => Response | Promise<Response>,
): Promise<Response> {
const path = normalizeMetricsPath(pathname);
const end = httpRequestDurationSeconds.startTimer({ method: req.method, path });
const res = await getResponse();
const statusCode = String(res.status);
httpRequestsTotal.inc({ method: req.method, path, status_code: statusCode });
end({ status_code: statusCode });
return res;
}

async function buildStaticRoutes(): Promise<Record<string, (req: Request) => Promise<Response>>> {
const routes: Record<string, (req: Request) => Promise<Response>> = {};
for await (const path of new Glob('**/*').scan(CLIENT_DIR)) {
const file = Bun.file(`${CLIENT_DIR}/${path}`);
const cache = getCacheHeaders(path);
routes[`/${path}`] = () =>
new Response(file, { headers: { 'Content-Type': file.type, ...cache } });
const routePath = `/${path}`;
routes[routePath] = (req) =>
withHttpMetrics(
req,
routePath,
() => new Response(file, { headers: { 'Content-Type': file.type, ...cache } }),
);
}
return routes;
}
Expand All @@ -54,11 +81,7 @@ const server = Bun.serve({
'/metrics': (req) => metricsResponse(req),
'/*': async (req) => {
const url = new URL(req.url);
const end = httpRequestDurationSeconds.startTimer({ method: req.method, path: url.pathname });
const res = await handler.fetch(req);
const statusCode = String(res.status);
httpRequestsTotal.inc({ method: req.method, path: url.pathname, status_code: statusCode });
end({ status_code: statusCode });
const res = await withHttpMetrics(req, url.pathname, () => handler.fetch(req));
const patched = new Response(res.body, res);
for (const [k, v] of Object.entries(NO_CACHE)) {
patched.headers.set(k, v);
Expand All @@ -71,5 +94,7 @@ const server = Bun.serve({
console.log(`Admin panel listening on http://localhost:${server.port}`);

if (!process.env.ADMIN_PANEL_METRICS_SECRET) {
console.warn('[metrics] ADMIN_PANEL_METRICS_SECRET is not set — /metrics will return 401 for all requests');
console.warn(
'[metrics] ADMIN_PANEL_METRICS_SECRET is not set — /metrics will return 401 for all requests',
);
}
38 changes: 38 additions & 0 deletions src/server/metrics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { normalizeMetricsPath } from './metrics';

describe('normalizeMetricsPath', () => {
it.each([
['/', '/'],
['/login', '/login'],
['/configuration/', '/configuration'],
['/auth/openid/callback', '/auth/openid/callback'],
])('keeps known app route %s bounded as %s', (input, expected) => {
expect(normalizeMetricsPath(input)).toBe(expected);
});

it.each([
['/assets/index-abc123.js'],
['/favicon.ico'],
['/manifest.json'],
['/clickhouse-dark.svg'],
])('buckets static asset %s', (input) => {
expect(normalizeMetricsPath(input)).toBe('static_asset');
});

it.each([
['/_server'],
['/_server/getUsersFn'],
['/_serverFn/getUsersFn'],
['/api/_server/getUsersFn'],
])('buckets server function path %s', (input) => {
expect(normalizeMetricsPath(input)).toBe('server_function');
});

it.each([['/wp-login.php'], ['/users/123'], ['not-a-path']])(
'buckets unknown path %s',
(input) => {
expect(normalizeMetricsPath(input)).toBe('unknown');
},
);
});
47 changes: 47 additions & 0 deletions src/server/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,53 @@ import client, { register } from 'prom-client';

client.collectDefaultMetrics();

const KNOWN_APP_ROUTES = new Map<string, string>([
['/', '/'],
['/login', '/login'],
['/access', '/access'],
['/configuration', '/configuration'],
['/grants', '/grants'],
['/help', '/help'],
['/users', '/users'],
['/auth/openid/callback', '/auth/openid/callback'],
]);

const STATIC_ASSET_RE =
/\.(?:avif|css|gif|ico|jpe?g|js|json|map|png|svg|txt|webmanifest|webp|woff2?)$/i;

const SERVER_FUNCTION_PREFIXES = [
'/_server',
'/_serverFn',
'/__server',
'/_tanstack',
'/api/_server',
];

function canonicalPath(pathname: string): string {
if (!pathname.startsWith('/')) return '/unknown';
if (pathname === '/') return pathname;
return pathname.replace(/\/+$/, '');
}

export function normalizeMetricsPath(pathname: string): string {
const path = canonicalPath(pathname);

const appRoute = KNOWN_APP_ROUTES.get(path);
if (appRoute) return appRoute;

if (path === '/metrics') return '/metrics';

if (path.startsWith('/assets/') || STATIC_ASSET_RE.test(path)) {
return 'static_asset';
}

if (SERVER_FUNCTION_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))) {
return 'server_function';
}

return 'unknown';
}

export const httpRequestsTotal = new client.Counter({
name: 'admin_http_requests_total',
help: 'Total number of HTTP requests',
Expand Down