mirror of https://github.com/requarks/wiki
parent
e8b6fcb3fc
commit
fb184f6375
@ -0,0 +1,48 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
|
export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||||
|
/**
|
||||||
|
* METRICS CONFIG - Used both ways: as the response, and as a partial update body
|
||||||
|
*/
|
||||||
|
app.addSchema({
|
||||||
|
$id: 'MetricsConfig',
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
isEnabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Whether the endpoint is served at all. While it is off nothing is registered at the path, so a wiki page there is served normally; turning it on is what makes the endpoint take that path over.'
|
||||||
|
},
|
||||||
|
path: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 512,
|
||||||
|
description:
|
||||||
|
"The URL path the exposition is served at, e.g. `/metrics`. Normalized to a single leading slash and no trailing one. Cannot be a segment starting with `_` (the wiki's own namespace) or a reserved root file such as `robots.txt`."
|
||||||
|
},
|
||||||
|
allowAnonymousLocal: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Allow scraping without credentials from this machine — `127.0.0.0/8`, `::1`.'
|
||||||
|
},
|
||||||
|
allowAnonymousPrivate: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Allow scraping without credentials from a private network — the RFC 1918 ranges, IPv6 unique local addresses, and link-local addresses.'
|
||||||
|
},
|
||||||
|
allowAnonymousExternal: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Allow scraping without credentials from any other address, i.e. serve the metrics publicly.'
|
||||||
|
},
|
||||||
|
includeRuntime: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'The Node.js process this scrape reached: CPU, memory, garbage collection, event loop lag, handles. Per instance, not cluster-wide.'
|
||||||
|
},
|
||||||
|
includeWiki: {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'The wiki itself: page, user, group, site, tag and asset totals, the job queue, pending suggested edits and scheduler health. Read from the database, so cluster-wide — and off by default, since each scrape of it runs about a dozen queries.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
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.')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Where a request came from, as far as the network is concerned.
|
||||||
|
*
|
||||||
|
* The classes an operator picks from when deciding who may scrape without credentials. They are
|
||||||
|
* about reachability, not identity: `local` is this machine, `private` is a network somebody had to
|
||||||
|
* already be inside, and `external` is everything else — including anything unrecognisable, since
|
||||||
|
* an address that cannot be placed must not land in the more permissive class.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import net from 'node:net'
|
||||||
|
|
||||||
|
export const CLIENT_IP_CLASSES = ['local', 'private', 'external'] as const
|
||||||
|
export type ClientIpClass = (typeof CLIENT_IP_CLASSES)[number]
|
||||||
|
|
||||||
|
/*
|
||||||
|
`net.BlockList` rather than parsing addresses by hand: it takes CIDR subnets directly, and it
|
||||||
|
matches an IPv4 rule against the IPv4-mapped IPv6 form of the same address (`::ffff:127.0.0.1`),
|
||||||
|
which is what a dual-stack listener hands over for an IPv4 client. Verified, not assumed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const loopback = new net.BlockList()
|
||||||
|
loopback.addSubnet('127.0.0.0', 8)
|
||||||
|
loopback.addAddress('::1', 'ipv6')
|
||||||
|
|
||||||
|
const privateNetworks = new net.BlockList()
|
||||||
|
// -> RFC 1918
|
||||||
|
privateNetworks.addSubnet('10.0.0.0', 8)
|
||||||
|
privateNetworks.addSubnet('172.16.0.0', 12)
|
||||||
|
privateNetworks.addSubnet('192.168.0.0', 16)
|
||||||
|
// -> Link-local (RFC 3927 / RFC 4291): unroutable, so it is reached from the same segment only
|
||||||
|
privateNetworks.addSubnet('169.254.0.0', 16)
|
||||||
|
privateNetworks.addSubnet('fe80::', 10, 'ipv6')
|
||||||
|
// -> Unique local addresses (RFC 4193), the IPv6 equivalent of the RFC 1918 ranges
|
||||||
|
privateNetworks.addSubnet('fc00::', 7, 'ipv6')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which class an address falls in.
|
||||||
|
*
|
||||||
|
* Anything that is not an IP address at all — a unix socket, an empty value — is `external`: this
|
||||||
|
* decides whether a request may skip authentication, so the unknown case has to be the strict one.
|
||||||
|
*
|
||||||
|
* Note that what an address MEANS depends on the `trustProxy` security setting. With it off, a wiki
|
||||||
|
* behind a reverse proxy sees every request as coming from the proxy, so a scrape from the far side
|
||||||
|
* of the internet reads as whatever the proxy's own address is.
|
||||||
|
*/
|
||||||
|
export function classifyClientIp(ip: string | null | undefined): ClientIpClass {
|
||||||
|
if (!ip) {
|
||||||
|
return 'external'
|
||||||
|
}
|
||||||
|
const type = net.isIPv6(ip) ? 'ipv6' : net.isIPv4(ip) ? 'ipv4' : null
|
||||||
|
if (!type) {
|
||||||
|
return 'external'
|
||||||
|
}
|
||||||
|
if (loopback.check(ip, type)) {
|
||||||
|
return 'local'
|
||||||
|
}
|
||||||
|
if (privateNetworks.check(ip, type)) {
|
||||||
|
return 'private'
|
||||||
|
}
|
||||||
|
return 'external'
|
||||||
|
}
|
||||||
@ -0,0 +1,313 @@
|
|||||||
|
import { Gauge, Registry, collectDefaultMetrics } from 'prom-client'
|
||||||
|
import { eq, sql } from 'drizzle-orm'
|
||||||
|
|
||||||
|
import { RESERVED_ROOT_FILES } from '../helpers/common.ts'
|
||||||
|
import { classifyClientIp } from '../helpers/network.ts'
|
||||||
|
import type { ClientIpClass } from '../helpers/network.ts'
|
||||||
|
import {
|
||||||
|
assets as assetsTable,
|
||||||
|
groups as groupsTable,
|
||||||
|
jobs as jobsTable,
|
||||||
|
pageEditSubmissions as submissionsTable,
|
||||||
|
pages as pagesTable,
|
||||||
|
sites as sitesTable,
|
||||||
|
tags as tagsTable,
|
||||||
|
users as usersTable
|
||||||
|
} from '../db/schema.ts'
|
||||||
|
|
||||||
|
/** Fields stored in the `metrics` settings blob. */
|
||||||
|
export const METRICS_FIELDS = [
|
||||||
|
'isEnabled',
|
||||||
|
'path',
|
||||||
|
'allowAnonymousLocal',
|
||||||
|
'allowAnonymousPrivate',
|
||||||
|
'allowAnonymousExternal',
|
||||||
|
'includeRuntime',
|
||||||
|
'includeWiki'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** The permission a scrape needs when its address is not one anonymous access was opened to. */
|
||||||
|
export const METRICS_PERMISSION = 'read:metrics'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which anonymous-access setting each address class is opened by.
|
||||||
|
*
|
||||||
|
* The classes come from `helpers/network.ts`; this is the only place that ties one to a setting, so
|
||||||
|
* adding a class is a matter of naming its field here.
|
||||||
|
*/
|
||||||
|
const ANONYMOUS_FIELD_BY_CLASS: Record<ClientIpClass, (typeof METRICS_FIELDS)[number]> = {
|
||||||
|
local: 'allowAnonymousLocal',
|
||||||
|
private: 'allowAnonymousPrivate',
|
||||||
|
external: 'allowAnonymousExternal'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The runtime registry, built once.
|
||||||
|
*
|
||||||
|
* `collectDefaultMetrics` attaches collectors to a registry permanently, and some of them (the GC
|
||||||
|
* histogram, the event loop lag probe) hold a handle open for the life of the process — so it has to
|
||||||
|
* happen once, not per scrape, and not at import time either: a wiki that never turns metrics on
|
||||||
|
* should not be carrying the probes.
|
||||||
|
*/
|
||||||
|
let runtimeRegistry: Registry | null = null
|
||||||
|
|
||||||
|
function runtimeRegistryFor(): Registry {
|
||||||
|
if (!runtimeRegistry) {
|
||||||
|
runtimeRegistry = new Registry()
|
||||||
|
collectDefaultMetrics({ register: runtimeRegistry })
|
||||||
|
}
|
||||||
|
return runtimeRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metrics model
|
||||||
|
*
|
||||||
|
* The Prometheus endpoint: the `metrics` settings blob the admin area edits, the decision about who
|
||||||
|
* may scrape it, and the exposition itself.
|
||||||
|
*
|
||||||
|
* Everything here is read per request through the `WIKI` global rather than captured at boot, so a
|
||||||
|
* change to the path or to who may reach it applies at once and on every instance — `saveToDb`
|
||||||
|
* propagates as `reloadConfig`, whose handler REPLACES `WIKI.config`.
|
||||||
|
*/
|
||||||
|
class Metrics {
|
||||||
|
/**
|
||||||
|
* The metrics configuration as the admin area expects it
|
||||||
|
*/
|
||||||
|
getConfig(): Record<string, any> {
|
||||||
|
const metrics = WIKI.config.metrics ?? {}
|
||||||
|
const config: Record<string, any> = {}
|
||||||
|
for (const field of METRICS_FIELDS) {
|
||||||
|
config[field] = metrics[field]
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only the fields this model owns, dropping anything else a client sends
|
||||||
|
*/
|
||||||
|
pickFields(body: Record<string, any>): Record<string, any> {
|
||||||
|
const patch: Record<string, any> = {}
|
||||||
|
for (const field of METRICS_FIELDS) {
|
||||||
|
if (body[field] !== undefined) {
|
||||||
|
patch[field] = body[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce a path to the single form it is compared against a request in.
|
||||||
|
*
|
||||||
|
* Wrapping and doubled slashes go and one leading slash is put back, so `metrics/`, `/metrics` and
|
||||||
|
* `//metrics//` are the same endpoint. Casing is left alone: a URL path is case-sensitive, and an
|
||||||
|
* administrator who writes `/Metrics` means that.
|
||||||
|
*/
|
||||||
|
normalizePath(input: unknown): string {
|
||||||
|
const segments = `${input ?? ''}`.trim().split('/').filter(Boolean)
|
||||||
|
return segments.length > 0 ? `/${segments.join('/')}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a patch against the settings it will end up merged with.
|
||||||
|
*
|
||||||
|
* @returns The reason it is invalid, or null when it is fine
|
||||||
|
*/
|
||||||
|
validate(patch: Record<string, any>): string | null {
|
||||||
|
const merged = { ...this.getConfig(), ...patch }
|
||||||
|
|
||||||
|
const path = this.normalizePath(merged.path)
|
||||||
|
if (!path) {
|
||||||
|
return 'The metrics path must name at least one segment, e.g. /metrics.'
|
||||||
|
}
|
||||||
|
if (/[\s?#]/.test(path)) {
|
||||||
|
return 'The metrics path cannot contain whitespace, a query string or a fragment.'
|
||||||
|
}
|
||||||
|
const firstSegment = path.split('/')[1] ?? ''
|
||||||
|
/*
|
||||||
|
The two namespaces that are not the wiki's to give away. A leading underscore is where the
|
||||||
|
server itself mounts (`/_api`, `/_files`, …) and where the frontend router expects its own
|
||||||
|
screens; the reserved root files are what a browser or a crawler asks for by convention. The
|
||||||
|
endpoint deliberately shadows a PAGE — that is the point of it — but shadowing the API or the
|
||||||
|
admin area would break the instance from a screen that cannot then be reached to undo it.
|
||||||
|
*/
|
||||||
|
if (firstSegment.startsWith('_')) {
|
||||||
|
return 'The metrics path cannot start with an underscore segment — those belong to the wiki itself.'
|
||||||
|
}
|
||||||
|
if (RESERVED_ROOT_FILES.has(path.slice(1).toLowerCase())) {
|
||||||
|
return `${path} is reserved.`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!merged.includeRuntime && !merged.includeWiki) {
|
||||||
|
return 'At least one group of metrics must be included, otherwise the endpoint has nothing to serve.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a validated patch, normalizing the path as it goes.
|
||||||
|
*
|
||||||
|
* @returns Whether the settings were saved
|
||||||
|
*/
|
||||||
|
async updateConfig(patch: Record<string, any>): Promise<boolean> {
|
||||||
|
const normalized = { ...patch }
|
||||||
|
if (normalized.path !== undefined) {
|
||||||
|
normalized.path = this.normalizePath(normalized.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousConfig = WIKI.config.metrics
|
||||||
|
WIKI.config.metrics = { ...previousConfig, ...normalized }
|
||||||
|
|
||||||
|
if (!(await WIKI.configSvc.saveToDb(['metrics']))) {
|
||||||
|
WIKI.config.metrics = previousConfig
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the endpoint is turned on at all. */
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return WIKI.config.metrics?.isEnabled === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this URL path is the metrics endpoint.
|
||||||
|
*
|
||||||
|
* False whenever the endpoint is off, which is what leaves a page at that path serving normally:
|
||||||
|
* nothing is registered as a route, so with metrics disabled the request carries on to the page
|
||||||
|
* tree exactly as it would have.
|
||||||
|
*
|
||||||
|
* The comparison forgives a trailing slash, since the server does elsewhere (`ignoreTrailingSlash`,
|
||||||
|
* and the redirect in the SEO hook) and a scrape configuration is written by hand.
|
||||||
|
*/
|
||||||
|
matches(urlPath: string): boolean {
|
||||||
|
if (!this.isEnabled()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const configured = this.normalizePath(WIKI.config.metrics?.path)
|
||||||
|
return configured.length > 0 && this.normalizePath(urlPath) === configured
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a scrape from this address may skip authentication.
|
||||||
|
*/
|
||||||
|
allowsAnonymous(ip: string | null | undefined): boolean {
|
||||||
|
const field = ANONYMOUS_FIELD_BY_CLASS[classifyClientIp(ip)]
|
||||||
|
return WIKI.config.metrics?.[field] === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exposition, in the Prometheus text format.
|
||||||
|
*
|
||||||
|
* The two groups are separate registries rather than one: the runtime collectors are attached for
|
||||||
|
* the life of the process, while the wiki gauges are read from the database and are therefore built
|
||||||
|
* and thrown away per scrape. Their outputs concatenate because the format is line-based and each
|
||||||
|
* metric carries its own HELP and TYPE.
|
||||||
|
*
|
||||||
|
* `groups` overrides which of the two are collected, for the admin area's preview: it renders what
|
||||||
|
* the form in front of the operator says rather than what was last saved, so that ticking a box and
|
||||||
|
* looking is one step instead of two. A scrape passes nothing and gets the stored settings.
|
||||||
|
*/
|
||||||
|
async render(groups?: {
|
||||||
|
includeRuntime?: boolean
|
||||||
|
includeWiki?: boolean
|
||||||
|
}): Promise<{ contentType: string; body: string }> {
|
||||||
|
const config = WIKI.config.metrics ?? {}
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
// -> Both read as opt-in rather than opt-out: `base.yml` gives every instance both keys, so an
|
||||||
|
// absent one is not a setting to be read generously — and the wiki group defaults to off
|
||||||
|
if ((groups?.includeRuntime ?? config.includeRuntime) === true) {
|
||||||
|
parts.push(await runtimeRegistryFor().metrics())
|
||||||
|
}
|
||||||
|
if ((groups?.includeWiki ?? config.includeWiki) === true) {
|
||||||
|
parts.push(await (await this.collectWikiMetrics()).metrics())
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentType: Registry.PROMETHEUS_CONTENT_TYPE,
|
||||||
|
body: parts.filter(Boolean).join('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A registry holding this instance's view of the wiki, read fresh.
|
||||||
|
*
|
||||||
|
* Every count is cluster-wide — they come out of the database, which every instance shares — with
|
||||||
|
* the exception of `wiki_info`'s instance id and the uptime, which are this process's. A scrape
|
||||||
|
* that lands on a different instance of an HA set therefore reports the same wiki and a different
|
||||||
|
* uptime, which is what those two labels are for.
|
||||||
|
*/
|
||||||
|
private async collectWikiMetrics(): Promise<Registry> {
|
||||||
|
const register = new Registry()
|
||||||
|
|
||||||
|
const gauge = (name: string, help: string) => new Gauge({ name, help, registers: [register] })
|
||||||
|
|
||||||
|
const [
|
||||||
|
pagesTotal,
|
||||||
|
usersTotal,
|
||||||
|
usersActive,
|
||||||
|
groupsTotal,
|
||||||
|
sitesTotal,
|
||||||
|
tagsTotal,
|
||||||
|
assetsAggregate,
|
||||||
|
jobsQueued,
|
||||||
|
jobsActive,
|
||||||
|
submissionsPending,
|
||||||
|
schedulerHealthy
|
||||||
|
] = await Promise.all([
|
||||||
|
WIKI.db.$count(pagesTable),
|
||||||
|
WIKI.db.$count(usersTable, eq(usersTable.isSystem, false)),
|
||||||
|
WIKI.db.$count(usersTable, sql`${usersTable.isSystem} = false AND ${usersTable.isActive}`),
|
||||||
|
WIKI.db.$count(groupsTable),
|
||||||
|
WIKI.db.$count(sitesTable),
|
||||||
|
WIKI.db.$count(tagsTable),
|
||||||
|
WIKI.db
|
||||||
|
.select({
|
||||||
|
total: sql<number>`count(*)::int`,
|
||||||
|
bytes: sql<number>`coalesce(sum(${assetsTable.fileSize}), 0)::bigint`
|
||||||
|
})
|
||||||
|
.from(assetsTable),
|
||||||
|
WIKI.db.$count(jobsTable),
|
||||||
|
WIKI.models.jobs.countActive(),
|
||||||
|
WIKI.db.$count(submissionsTable),
|
||||||
|
WIKI.models.jobs.isHealthy()
|
||||||
|
])
|
||||||
|
|
||||||
|
new Gauge({
|
||||||
|
name: 'wiki_info',
|
||||||
|
help: 'Wiki.js build and instance this scrape was answered by. Always 1.',
|
||||||
|
labelNames: ['version', 'instance'],
|
||||||
|
registers: [register]
|
||||||
|
}).set({ version: WIKI.version, instance: WIKI.INSTANCE_ID }, 1)
|
||||||
|
|
||||||
|
gauge('wiki_start_time_seconds', 'Unix time at which this instance finished booting.').set(
|
||||||
|
WIKI.startedAt.epochMilliseconds / 1000
|
||||||
|
)
|
||||||
|
gauge('wiki_pages_total', 'Pages across every site and locale.').set(pagesTotal)
|
||||||
|
gauge('wiki_users_total', 'User accounts, excluding the system accounts.').set(usersTotal)
|
||||||
|
gauge('wiki_users_active_total', 'User accounts that are active, i.e. able to log in.').set(
|
||||||
|
usersActive
|
||||||
|
)
|
||||||
|
gauge('wiki_groups_total', 'Groups.').set(groupsTotal)
|
||||||
|
gauge('wiki_sites_total', 'Sites served by this wiki.').set(sitesTotal)
|
||||||
|
gauge('wiki_tags_total', 'Distinct tags in use.').set(tagsTotal)
|
||||||
|
gauge('wiki_assets_total', 'Uploaded files.').set(assetsAggregate[0]?.total ?? 0)
|
||||||
|
gauge('wiki_assets_size_bytes', 'Total size of uploaded files.').set(
|
||||||
|
Number(assetsAggregate[0]?.bytes ?? 0)
|
||||||
|
)
|
||||||
|
gauge('wiki_jobs_queued', 'Jobs waiting to be picked up by an instance.').set(jobsQueued)
|
||||||
|
gauge('wiki_jobs_active', 'Jobs running right now, across every instance.').set(jobsActive)
|
||||||
|
gauge('wiki_page_edit_submissions_pending', 'Suggested edits waiting for a reviewer.').set(
|
||||||
|
submissionsPending
|
||||||
|
)
|
||||||
|
gauge(
|
||||||
|
'wiki_scheduler_healthy',
|
||||||
|
'Whether some instance is still queueing scheduled jobs. 1 or 0.'
|
||||||
|
).set(schedulerHealthy ? 1 : 0)
|
||||||
|
|
||||||
|
return register
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metrics = new Metrics()
|
||||||
Loading…
Reference in new issue