feat(threads): thread and forum API, admin, schemas and docs (#3251)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 15:25:12 +02:00
1 parent 5f4295e399
commit 3f4160b138
495 files changed
+39232 -1215

No files matched your search

+65 -16
View File
@@ -2,7 +2,7 @@
import {isLoopbackIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import type {HttpBindings} from '@hono/node-server';
import type {Handler, MiddlewareHandler} from 'hono';
import type {Context, Handler, MiddlewareHandler} from 'hono';
const SKIP_PATHS = new Set(['/_health', '/_healthz', '/_metrics']);
@@ -24,11 +24,11 @@ function statusClass(status: number): string {
return '5xx';
}
class Counter {
export class Counter {
private labels = new Map<string, number>();
inc(labelKey: string = ''): void {
this.labels.set(labelKey, (this.labels.get(labelKey) ?? 0) + 1);
inc(labelKey: string = '', amount = 1): void {
this.labels.set(labelKey, (this.labels.get(labelKey) ?? 0) + amount);
}
render(name: string, help: string): string {
@@ -50,7 +50,7 @@ class Counter {
}
}
class Histogram {
export class Histogram {
private readonly buckets: Array<number>;
private readonly counts: Array<number>;
private sum = 0;
@@ -85,7 +85,7 @@ class Histogram {
}
}
class Gauge {
export class Gauge {
private valueFn: () => number;
constructor(valueFn: () => number) {
@@ -119,6 +119,62 @@ function renderRegisteredSections(): Array<string> {
return rendered;
}
interface RegisteredMetric {
help: string;
metric: Counter | Histogram | Gauge;
}
const registeredMetrics = new Map<string, RegisteredMetric>();
function register<T extends Counter | Histogram | Gauge>(
name: string,
help: string,
create: () => T,
kind: new (...args: Array<never>) => T,
): T {
const existing = registeredMetrics.get(name);
if (existing !== undefined) {
if (!(existing.metric instanceof kind)) {
throw new Error(`metric ${name} is already registered with another type`);
}
return existing.metric;
}
const metric = create();
registeredMetrics.set(name, {help, metric});
return metric;
}
export function registerCounter(name: string, help: string): Counter {
return register(name, help, () => new Counter(), Counter);
}
export function registerHistogram(name: string, help: string, buckets: Array<number> = DEFAULT_BUCKETS): Histogram {
return register(name, help, () => new Histogram(buckets), Histogram);
}
export function registerGauge(name: string, help: string, valueFn: () => number): Gauge {
return register(name, help, () => new Gauge(valueFn), Gauge);
}
function renderRegisteredMetrics(): Array<string> {
return [...registeredMetrics].map(([name, {help, metric}]) => metric.render(name, help));
}
const METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
function renderMetricsResponse(c: Context, sections: Array<string>): Response {
if (!isLoopbackPeer(c.env)) {
return c.text('FORBIDDEN', 403, {'Content-Type': 'text/plain'});
}
return c.text(`${[...sections, ...renderRegisteredMetrics()].join('\n\n')}\n`, 200, {
'Content-Type': METRICS_CONTENT_TYPE,
});
}
export function createRegisteredMetricsHandler(): Handler {
return (c) => renderMetricsResponse(c, []);
}
interface MetricsState {
requestsTotal: Counter;
errorsTotal: Counter;
@@ -165,21 +221,14 @@ export function createMetricsMiddleware(serviceName: string): MetricsResult {
}
};
const metricsHandler: Handler = (c) => {
if (!isLoopbackPeer(c.env)) {
return c.text('FORBIDDEN', 403, {'Content-Type': 'text/plain'});
}
const sections = [
const metricsHandler: Handler = (c) =>
renderMetricsResponse(c, [
requestsTotal.render(`${prefix}_http_requests_total`, 'Total HTTP requests'),
requestDuration.render(`${prefix}_http_request_duration_seconds`, 'HTTP request duration in seconds'),
errorsTotal.render(`${prefix}_http_errors_total`, 'Total HTTP 5xx errors'),
uptime.render(`${prefix}_uptime_seconds`, 'Process uptime in seconds'),
...renderRegisteredSections(),
];
return c.text(`${sections.join('\n\n')}\n`, 200, {
'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
});
};
]);
return {middleware, metricsHandler, state};
}
@@ -1,6 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createMetricsMiddleware, registerMetricsSection} from '@fluxer/hono/src/middleware/Metrics';
import {
createMetricsMiddleware,
createRegisteredMetricsHandler,
registerCounter,
registerGauge,
registerHistogram,
registerMetricsSection,
} from '@fluxer/hono/src/middleware/Metrics';
import {Hono} from 'hono';
import {describe, expect, test} from 'vitest';
@@ -265,4 +272,44 @@ describe('Metrics Middleware', () => {
expect(await (await requestMetrics(app)).text()).not.toContain('fluxer_test_extra');
});
});
describe('registered metrics', () => {
test('renders registered counters, gauges and histograms after the http metrics', async () => {
const counter = registerCounter('fluxer_test_registered_total', 'Registered counter');
counter.inc('kind="a"');
counter.inc('kind="a"', 2);
registerGauge('fluxer_test_registered_gauge', 'Registered gauge', () => 7);
registerHistogram('fluxer_test_registered_seconds', 'Registered histogram', [1, 5]).observe(2);
const {app} = createTestApp();
const body = await (await requestMetrics(app)).text();
expect(body).toContain('# TYPE fluxer_test_registered_total counter');
expect(body).toContain('fluxer_test_registered_total{kind="a"} 3');
expect(body).toContain('fluxer_test_registered_gauge 7');
expect(body).toContain('fluxer_test_registered_seconds_bucket{le="5"} 1');
expect(body.indexOf('fluxer_test_uptime_seconds')).toBeLessThan(body.indexOf('fluxer_test_registered_total'));
});
test('returns the same metric when registered twice', () => {
const first = registerCounter('fluxer_test_registered_twice_total', 'Registered twice');
expect(registerCounter('fluxer_test_registered_twice_total', 'Registered twice')).toBe(first);
});
test('refuses to register one name as two types', () => {
registerCounter('fluxer_test_registered_clash', 'Clash');
expect(() => registerGauge('fluxer_test_registered_clash', 'Clash', () => 0)).toThrow();
});
test('serves only registered metrics from the standalone handler', async () => {
registerCounter('fluxer_test_standalone_total', 'Standalone counter').inc();
const app = new Hono();
app.get('/_metrics', createRegisteredMetricsHandler());
const res = await requestMetrics(app);
const body = await res.text();
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toBe('text/plain; version=0.0.4; charset=utf-8');
expect(body).toContain('fluxer_test_standalone_total 1');
expect(body).not.toContain('_http_requests_total');
expect((await requestMetrics(app, '8.8.8.8')).status).toBe(403);
});
});
});