mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
- `/api/metrics` in Prometheus text format, off until `METRICS_TOKEN` or `METRICS_TOKEN_FILE` is set (404 before, 401 without the bearer token): requests per route group with status class and latency, sign-in outcomes, calls to Stalwart, WOPI, translation, the plugin proxy and OAuth, browser errors, errors today, process memory, event-loop lag, uptime and build info. Labels come from fixed lists. Each replica serves its own. - `/api/health/live` answers while the process runs; `/api/health/ready` checks the config, the shared store, writable data dirs and Stalwart's session resource. `/api/health` and `/api/ready` are unchanged. - the Docker image gets a HEALTHCHECK on the live endpoint - adds prom-client, and the OpenTelemetry packages the tracing step loads on demand
105 lines
3.8 KiB
TypeScript
105 lines
3.8 KiB
TypeScript
// @vitest-environment node
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { NextRequest } from 'next/server';
|
|
|
|
const config: Record<string, unknown> = {};
|
|
vi.mock('@/lib/admin/config-manager', () => ({
|
|
configManager: {
|
|
ensureLoaded: async () => {},
|
|
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
|
|
},
|
|
}));
|
|
|
|
import { POST } from '../route';
|
|
import { flushErrors, readErrors, resetAggregateForTests, utcDay } from '@/lib/error-reporting/aggregate';
|
|
import { metrics, resetMetricsForTests } from '@/lib/metrics';
|
|
|
|
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
|
|
const SUBJECT = 'Merger plans for Acme Q3';
|
|
|
|
let dir: string;
|
|
let ip = 0;
|
|
|
|
function post(body: unknown): NextRequest {
|
|
ip += 1;
|
|
return new NextRequest('http://localhost/api/errors', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', 'x-forwarded-for': `203.0.113.${ip}` },
|
|
body: typeof body === 'string' ? body : JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
const report = {
|
|
name: 'TypeError',
|
|
message: `Cannot open "${SUBJECT}"`,
|
|
frames: [{ path: `https://mail.example.org/_next/static/chunks/1.js?subject=${encodeURIComponent(SUBJECT)}`, line: 2, column: 30, fn: 'Ab' }],
|
|
version: '1.20.0',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(path.join(tmpdir(), 'bulwark-errors-route-'));
|
|
process.env.TELEMETRY_DATA_DIR = dir;
|
|
for (const key of Object.keys(config)) delete config[key];
|
|
resetAggregateForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetAggregateForTests();
|
|
delete process.env.TELEMETRY_DATA_DIR;
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('POST /api/errors', () => {
|
|
it('keeps nothing while error collection is off', async () => {
|
|
const res = await POST(post(report));
|
|
expect(res.status).toBe(204);
|
|
await flushErrors();
|
|
expect(await readErrors()).toEqual({});
|
|
});
|
|
|
|
it('counts a report, scrubbed again, without its message', async () => {
|
|
config.errorReportingDsn = DSN;
|
|
expect((await POST(post(report))).status).toBe(204);
|
|
await flushErrors();
|
|
const stored = JSON.stringify(await readErrors());
|
|
expect(stored).not.toContain('Merger');
|
|
expect(stored).not.toContain('subject=');
|
|
const [entry] = Object.values((await readErrors())[utcDay()]);
|
|
expect(entry).toMatchObject({ name: 'TypeError', runtime: 'browser', version: '1.20.0', count: 1 });
|
|
expect(entry.frames).toEqual([{ path: '/_next/static/chunks/1.js', line: 2, column: 30, fn: 'Ab' }]);
|
|
});
|
|
|
|
it('counts while only the metrics endpoint is on, for the browser errors metric', async () => {
|
|
process.env.METRICS_TOKEN = 'scrape-secret-0123456789';
|
|
resetMetricsForTests();
|
|
try {
|
|
expect((await POST(post(report))).status).toBe(204);
|
|
await flushErrors();
|
|
expect(Object.keys((await readErrors())[utcDay()] ?? {})).toHaveLength(1);
|
|
expect(await metrics().registry.metrics()).toContain('bulwark_browser_errors_total 1');
|
|
} finally {
|
|
delete process.env.METRICS_TOKEN;
|
|
resetMetricsForTests();
|
|
}
|
|
});
|
|
|
|
it('ignores a name or code that is not an identifier or fixed code', async () => {
|
|
config.errorReportingDsn = DSN;
|
|
await POST(post({ ...report, name: SUBJECT, code: SUBJECT }));
|
|
await flushErrors();
|
|
const [entry] = Object.values((await readErrors())[utcDay()]);
|
|
expect(entry.name).toBe('Error');
|
|
expect(entry.code).toBeUndefined();
|
|
});
|
|
|
|
it('refuses bodies that are too large or not JSON objects', async () => {
|
|
config.errorReportingDsn = DSN;
|
|
expect((await POST(post('x'.repeat(17 * 1024)))).status).toBe(413);
|
|
expect((await POST(post('[1,2]'))).status).toBe(400);
|
|
expect((await POST(post('not json'))).status).toBe(400);
|
|
});
|
|
});
|