You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/backend/controllers/metrics.ts

64 lines
2.8 KiB

import type { FastifyReply, FastifyRequest } from 'fastify'
import { METRICS_PERMISSION } from '../models/metrics.ts'
/**
* The Prometheus metrics endpoint.
*
* A hook rather than a route, because the path is a setting and a route table is fixed at boot. It
* is registered on the root instance in `index.ts` and does nothing at all unless metrics are turned
* on and the path matches — which is what lets a page live at `/metrics` while the endpoint is off,
* and is why the check has to come BEFORE the SEO hook: that one would otherwise redirect the scrape
* to the site's locale prefix, or strip a page extension off it.
*
* Authentication is either-or. An address in a class the operator opened scrapes anonymously; every
* other address has to carry `read:metrics`, as a bearer API key (verified by the hook above this
* one, which lets the metrics path through for exactly this reason) or as the session cookie of a
* logged-in browser. `manage:system` bypasses it, as it does everywhere.
*/
export async function metricsHook(req: FastifyRequest, reply: FastifyReply) {
// -> This runs ahead of every request the wiki serves, so the disabled case — which is most wikis,
// always — costs one property read and nothing else
if (!WIKI.models.metrics.isEnabled()) {
return
}
if (!WIKI.models.metrics.matches(req.raw.url!.split('?')[0]!)) {
return
}
// -> A scrape reads; nothing here answers a POST, and saying so is more useful than a 404 at a
// path that plainly exists
if (req.method !== 'GET' && req.method !== 'HEAD') {
return reply.methodNotAllowed()
}
if (!WIKI.models.metrics.allowsAnonymous(req.ip)) {
const permissions = req.apiKey
? req.apiKey.permissions
: req.session?.authenticated
? req.session.permissions
: null
const isAllowed =
permissions?.includes(METRICS_PERMISSION) || permissions?.includes('manage:system')
if (!isAllowed) {
/*
401 rather than 403 even for a caller who is authenticated but unentitled: the answer a
scraper needs is "send a credential", and the two cases are not worth distinguishing to
somebody the endpoint is not open to anyway.
*/
return reply
.header('WWW-Authenticate', 'Bearer realm="metrics"')
.unauthorized('This endpoint requires the read:metrics permission.')
}
}
try {
const { contentType, body } = await WIKI.models.metrics.render()
// -> Never held: a scrape a minute old is worse than no scrape, and Prometheus asks again anyway
return reply.header('Cache-Control', 'no-store').type(contentType).send(body)
} catch (err: any) {
WIKI.logger.warn(`Failed to collect metrics: ${err.message}`)
return reply.internalServerError('Failed to collect metrics.')
}
}