From dec9272fbf0effa1a0ab1105af04fc73e2d948da Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sat, 11 Nov 2023 12:13:07 +0000 Subject: [PATCH] feat: metrics endpoint --- server/app/data.yml | 2 + server/controllers/common.mjs | 15 ++ server/core/kernel.mjs | 2 + server/core/metrics.mjs | 66 ++++++++ server/db/migrations/3.0.0.mjs | 12 ++ server/graph/resolvers/system.mjs | 24 +++ server/graph/schemas/system.graphql | 5 + server/locales/en.json | 10 ++ server/package.json | 1 + server/pnpm-lock.yaml | 21 +++ ux/public/_assets/icons/fluent-graph.svg | 1 + ux/src/layouts/AdminLayout.vue | 6 + ux/src/pages/AdminMetrics.vue | 188 +++++++++++++++++++++++ ux/src/router/routes.js | 1 + ux/src/stores/admin.js | 2 + 15 files changed, 356 insertions(+) create mode 100644 server/core/metrics.mjs create mode 100644 ux/public/_assets/icons/fluent-graph.svg create mode 100644 ux/src/pages/AdminMetrics.vue diff --git a/server/app/data.yml b/server/app/data.yml index 4d0ef92bc0..12535a18a6 100644 --- a/server/app/data.yml +++ b/server/app/data.yml @@ -41,6 +41,8 @@ defaults: host: '' secure: true verifySSL: true + metrics: + isEnabled: false auth: autoLogin: false enforce2FA: false diff --git a/server/controllers/common.mjs b/server/controllers/common.mjs index 5ffe883081..b796eed66e 100644 --- a/server/controllers/common.mjs +++ b/server/controllers/common.mjs @@ -434,6 +434,21 @@ export default function () { return res.sendStatus(404) }) + /** + * Metrics (Prometheus) + */ + router.get('/metrics', async (req, res, next) => { + if (!WIKI.auth.checkAccess(req.user, ['read:metrics'])) { + return res.sendStatus(403) + } + + if (WIKI.config.metrics.isEnabled) { + WIKI.metrics.render(res) + } else { + next() + } + }) + // /** // * View document / asset // */ diff --git a/server/core/kernel.mjs b/server/core/kernel.mjs index 560b782415..eb0a1d7c01 100644 --- a/server/core/kernel.mjs +++ b/server/core/kernel.mjs @@ -7,6 +7,7 @@ import db from './db.mjs' import extensions from './extensions.mjs' import scheduler from './scheduler.mjs' import servers from './servers.mjs' +import metrics from './metrics.mjs' let isShuttingDown = false @@ -47,6 +48,7 @@ export default { } WIKI.extensions = extensions WIKI.asar = asar + WIKI.metrics = await metrics.init() } catch (err) { WIKI.logger.error(err) process.exit(1) diff --git a/server/core/metrics.mjs b/server/core/metrics.mjs new file mode 100644 index 0000000000..9ffa25df71 --- /dev/null +++ b/server/core/metrics.mjs @@ -0,0 +1,66 @@ +import { collectDefaultMetrics, register, Gauge } from 'prom-client' +import { toSafeInteger } from 'lodash-es' + +export default { + customMetrics: {}, + async init () { + if (WIKI.config.metrics.isEnabled) { + WIKI.logger.info('Initializing metrics...') + + register.setDefaultLabels({ + WIKI_INSTANCE: WIKI.INSTANCE_ID + }) + + collectDefaultMetrics() + + this.customMetrics.groupsTotal = new Gauge({ + name: 'wiki_groups_total', + help: 'Total number of groups', + async collect() { + const total = await WIKI.db.groups.query().count('* as total').first() + this.set(toSafeInteger(total.total)) + } + }) + + this.customMetrics.pagesTotal = new Gauge({ + name: 'wiki_pages_total', + help: 'Total number of pages', + async collect() { + const total = await WIKI.db.pages.query().count('* as total').first() + this.set(toSafeInteger(total.total)) + } + }) + + this.customMetrics.tagsTotal = new Gauge({ + name: 'wiki_tags_total', + help: 'Total number of tags', + async collect() { + const total = await WIKI.db.tags.query().count('* as total').first() + this.set(toSafeInteger(total.total)) + } + }) + + this.customMetrics.usersTotal = new Gauge({ + name: 'wiki_users_total', + help: 'Total number of users', + async collect() { + const total = await WIKI.db.users.query().count('* as total').first() + this.set(toSafeInteger(total.total)) + } + }) + WIKI.logger.info('Metrics ready [ OK ]') + } else { + this.customMetrics = {} + register.clear() + } + return this + }, + async render (res) { + try { + res.contentType(register.contentType) + res.send(await register.metrics()) + } catch (err) { + res.status(500).end(err.message) + } + } +} diff --git a/server/db/migrations/3.0.0.mjs b/server/db/migrations/3.0.0.mjs index c1f3c39c11..795d3c4d38 100644 --- a/server/db/migrations/3.0.0.mjs +++ b/server/db/migrations/3.0.0.mjs @@ -457,6 +457,12 @@ export async function up (knex) { }) await knex('settings').insert([ + { + key: 'api', + value: { + isEnabled: false + } + }, { key: 'auth', value: { @@ -516,6 +522,12 @@ export async function up (knex) { dkimPrivateKey: '' } }, + { + key: 'metrics', + value: { + isEnabled: false + } + }, { key: 'search', value: { diff --git a/server/graph/resolvers/system.mjs b/server/graph/resolvers/system.mjs index 4fd7d89876..6f3eb48635 100644 --- a/server/graph/resolvers/system.mjs +++ b/server/graph/resolvers/system.mjs @@ -12,6 +12,12 @@ const getos = util.promisify(getosSync) export default { Query: { + /** + * Metrics Endpoint State + */ + metricsState () { + return WIKI.config.metrics?.isEnabled ?? false + }, /** * System Flags */ @@ -281,6 +287,24 @@ export default { return generateError(err) } }, + /** + * Set Metrics endpoint state + */ + async setMetricsState (obj, args, context) { + try { + if (!WIKI.auth.checkAccess(context.req.user, ['manage:system'])) { + throw new Error('ERR_FORBIDDEN') + } + + WIKI.config.metrics.isEnabled = args.enabled + await WIKI.configSvc.saveToDb(['metrics']) + return { + operation: generateSuccess('Metrics endpoint state changed successfully') + } + } catch (err) { + return generateError(err) + } + }, async updateSystemFlags (obj, args, context) { try { if (!WIKI.auth.checkAccess(context.req.user, ['manage:system'])) { diff --git a/server/graph/schemas/system.graphql b/server/graph/schemas/system.graphql index 4da13974f6..deff656d9e 100644 --- a/server/graph/schemas/system.graphql +++ b/server/graph/schemas/system.graphql @@ -3,6 +3,7 @@ # =============================================== extend type Query { + metricsState: Boolean systemExtensions: [SystemExtension] systemFlags: JSON systemInfo: SystemInfo @@ -35,6 +36,10 @@ extend type Mutation { id: UUID! ): DefaultResponse + setMetricsState( + enabled: Boolean! + ): DefaultResponse + updateSystemSearch( termHighlighting: Boolean dictOverrides: String diff --git a/server/locales/en.json b/server/locales/en.json index 125cb97836..a3dc805939 100644 --- a/server/locales/en.json +++ b/server/locales/en.json @@ -466,6 +466,16 @@ "admin.mail.testRecipientHint": "Email address that should receive the test email.", "admin.mail.testSend": "Send Email", "admin.mail.title": "Mail", + "admin.metrics.auth": "You must provide the {headerName} header with a {tokenType} token. Generate an API key with the {permission} permission and use it as the token.", + "admin.metrics.disabled": "Endpoint Disabled", + "admin.metrics.enabled": "Endpoint Enabled", + "admin.metrics.endpoint": "The metrics endpoint can be scraped at {endpoint}", + "admin.metrics.endpointWarning": "Note that this override any page at this path.", + "admin.metrics.refreshSuccess": "Metrics endpoint state has been refreshed.", + "admin.metrics.subtitle": "Manage the Prometheus metrics endpoint", + "admin.metrics.title": "Metrics", + "admin.metrics.toggleStateDisabledSuccess": "Metrics endpoint disabled successfully.", + "admin.metrics.toggleStateEnabledSuccess": "Metrics endpoint enabled successfully.", "admin.nav.modules": "Modules", "admin.nav.site": "Site", "admin.nav.system": "System", diff --git a/server/package.json b/server/package.json index 86658f2489..9b704e3c83 100644 --- a/server/package.json +++ b/server/package.json @@ -149,6 +149,7 @@ "pg-query-stream": "4.5.3", "pg-tsquery": "8.4.1", "poolifier": "2.7.5", + "prom-client": "15.0.0", "punycode": "2.3.0", "puppeteer-core": "21.4.0", "qr-image": "3.2.0", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index fd26a6f58f..a30e79d66f 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -344,6 +344,9 @@ dependencies: poolifier: specifier: 2.7.5 version: 2.7.5 + prom-client: + specifier: 15.0.0 + version: 15.0.0 punycode: specifier: 2.3.0 version: 2.3.0 @@ -2252,6 +2255,10 @@ packages: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} + /bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + dev: false + /bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} dependencies: @@ -6138,6 +6145,14 @@ packages: engines: {node: '>=0.4.0'} dev: false + /prom-client@15.0.0: + resolution: {integrity: sha512-UocpgIrKyA2TKLVZDSfm8rGkL13C19YrQBAiG3xo3aDFWcHedxRxI3z+cIcucoxpSO0h5lff5iv/SXoxyeopeA==} + engines: {node: ^16 || ^18 || >=20} + dependencies: + '@opentelemetry/api': 1.6.0 + tdigest: 0.1.2 + dev: false + /promised-retry@0.5.0: resolution: {integrity: sha512-jbYvN6UGE+/3E1g0JmgDPchUc+4VI4cBaPjdr2Lso22xfFqut2warEf6IhWuhPJKbJYVOQAyCt2Jx+01ORCItg==} engines: {node: ^14.17.0 || >=16.0.0} @@ -6967,6 +6982,12 @@ packages: engines: {node: '>=8.0.0'} dev: false + /tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + dependencies: + bintrees: 1.0.2 + dev: false + /text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true diff --git a/ux/public/_assets/icons/fluent-graph.svg b/ux/public/_assets/icons/fluent-graph.svg new file mode 100644 index 0000000000..ce8f29cf66 --- /dev/null +++ b/ux/public/_assets/icons/fluent-graph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ux/src/layouts/AdminLayout.vue b/ux/src/layouts/AdminLayout.vue index 051fafd8b2..c251565377 100644 --- a/ux/src/layouts/AdminLayout.vue +++ b/ux/src/layouts/AdminLayout.vue @@ -181,6 +181,12 @@ q-layout.admin(view='hHh Lpr lff') q-item-section {{ t('admin.mail.title') }} q-item-section(side) status-light(:color='adminStore.info.isMailConfigured ? `positive` : `warning`', :pulse='!adminStore.info.isMailConfigured') + q-item(to='/_admin/metrics', v-ripple, active-class='bg-primary text-white') + q-item-section(avatar) + q-icon(name='img:/_assets/icons/fluent-graph.svg') + q-item-section {{ t('admin.metrics.title') }} + q-item-section(side) + status-light(:color='adminStore.info.isMetricsEnabled ? `positive` : `negative`') q-item(to='/_admin/rendering', v-ripple, active-class='bg-primary text-white') q-item-section(avatar) q-icon(name='img:/_assets/icons/fluent-rich-text-converter.svg') diff --git a/ux/src/pages/AdminMetrics.vue b/ux/src/pages/AdminMetrics.vue new file mode 100644 index 0000000000..cfc0c7e2f7 --- /dev/null +++ b/ux/src/pages/AdminMetrics.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/ux/src/router/routes.js b/ux/src/router/routes.js index 0d23e5bfe4..8a3b15e662 100644 --- a/ux/src/router/routes.js +++ b/ux/src/router/routes.js @@ -62,6 +62,7 @@ const routes = [ { path: 'icons', component: () => import('pages/AdminIcons.vue') }, { path: 'instances', component: () => import('pages/AdminInstances.vue') }, { path: 'mail', component: () => import('pages/AdminMail.vue') }, + { path: 'metrics', component: () => import('pages/AdminMetrics.vue') }, { path: 'rendering', component: () => import('pages/AdminRendering.vue') }, { path: 'scheduler', component: () => import('pages/AdminScheduler.vue') }, { path: 'search', component: () => import('pages/AdminSearch.vue') }, diff --git a/ux/src/stores/admin.js b/ux/src/stores/admin.js index 0562e52f16..4a6c2e03af 100644 --- a/ux/src/stores/admin.js +++ b/ux/src/stores/admin.js @@ -56,6 +56,7 @@ export const useAdminStore = defineStore('admin', { query: gql` query getAdminInfo { apiState + metricsState systemInfo { groupsTotal tagsTotal @@ -77,6 +78,7 @@ export const useAdminStore = defineStore('admin', { this.info.currentVersion = clone(resp?.data?.systemInfo?.currentVersion ?? 'n/a') this.info.latestVersion = clone(resp?.data?.systemInfo?.latestVersion ?? 'n/a') this.info.isApiEnabled = clone(resp?.data?.apiState ?? false) + this.info.isMetricsEnabled = clone(resp?.data?.metricsState ?? false) this.info.isMailConfigured = clone(resp?.data?.systemInfo?.isMailConfigured ?? false) this.info.isSchedulerHealthy = clone(resp?.data?.systemInfo?.isSchedulerHealthy ?? false) },