diff --git a/apps/server/src/etapi/metrics.ts b/apps/server/src/etapi/metrics.ts new file mode 100644 index 000000000..54c301838 --- /dev/null +++ b/apps/server/src/etapi/metrics.ts @@ -0,0 +1,267 @@ +import type { Router } from "express"; +import eu from "./etapi_utils.js"; +import sql from "../services/sql.js"; +import appInfo from "../services/app_info.js"; + +interface MetricsData { + version: { + app: string; + db: number; + node: string; + sync: number; + buildDate: string; + buildRevision: string; + }; + database: { + totalNotes: number; + deletedNotes: number; + activeNotes: number; + protectedNotes: number; + totalAttachments: number; + deletedAttachments: number; + activeAttachments: number; + totalRevisions: number; + totalBranches: number; + totalAttributes: number; + totalBlobs: number; + totalEtapiTokens: number; + totalRecentNotes: number; + totalEmbeddings: number; + totalEmbeddingProviders: number; + }; + noteTypes: Record; + attachmentTypes: Record; + statistics: { + oldestNote: string | null; + newestNote: string | null; + lastModified: string | null; + databaseSizeBytes: number | null; + }; + timestamp: string; +} + +/** + * Converts metrics data to Prometheus text format + */ +function formatPrometheusMetrics(data: MetricsData): string { + const lines: string[] = []; + const timestamp = Math.floor(new Date(data.timestamp).getTime() / 1000); + + // Helper function to add a metric + const addMetric = (name: string, value: number | null, help: string, type: string = 'gauge', labels: Record = {}) => { + if (value === null) return; + + lines.push(`# HELP ${name} ${help}`); + lines.push(`# TYPE ${name} ${type}`); + + const labelStr = Object.entries(labels).length > 0 + ? `{${Object.entries(labels).map(([k, v]) => `${k}="${v}"`).join(',')}}` + : ''; + + lines.push(`${name}${labelStr} ${value} ${timestamp}`); + lines.push(''); + }; + + // Version info + addMetric('trilium_info', 1, 'Trilium instance information', 'gauge', { + version: data.version.app, + db_version: data.version.db.toString(), + node_version: data.version.node, + sync_version: data.version.sync.toString(), + build_date: data.version.buildDate, + build_revision: data.version.buildRevision + }); + + // Database metrics + addMetric('trilium_notes_total', data.database.totalNotes, 'Total number of notes including deleted'); + addMetric('trilium_notes_deleted', data.database.deletedNotes, 'Number of deleted notes'); + addMetric('trilium_notes_active', data.database.activeNotes, 'Number of active notes'); + addMetric('trilium_notes_protected', data.database.protectedNotes, 'Number of protected notes'); + + addMetric('trilium_attachments_total', data.database.totalAttachments, 'Total number of attachments including deleted'); + addMetric('trilium_attachments_deleted', data.database.deletedAttachments, 'Number of deleted attachments'); + addMetric('trilium_attachments_active', data.database.activeAttachments, 'Number of active attachments'); + + addMetric('trilium_revisions_total', data.database.totalRevisions, 'Total number of note revisions'); + addMetric('trilium_branches_total', data.database.totalBranches, 'Number of active branches'); + addMetric('trilium_attributes_total', data.database.totalAttributes, 'Number of active attributes'); + addMetric('trilium_blobs_total', data.database.totalBlobs, 'Total number of blob records'); + addMetric('trilium_etapi_tokens_total', data.database.totalEtapiTokens, 'Number of active ETAPI tokens'); + addMetric('trilium_recent_notes_total', data.database.totalRecentNotes, 'Number of recent notes tracked'); + addMetric('trilium_embeddings_total', data.database.totalEmbeddings, 'Number of note embeddings'); + addMetric('trilium_embedding_providers_total', data.database.totalEmbeddingProviders, 'Number of embedding providers'); + + // Note types + for (const [type, count] of Object.entries(data.noteTypes)) { + addMetric('trilium_notes_by_type', count, 'Number of notes by type', 'gauge', { type }); + } + + // Attachment types + for (const [mime, count] of Object.entries(data.attachmentTypes)) { + addMetric('trilium_attachments_by_type', count, 'Number of attachments by MIME type', 'gauge', { mime_type: mime }); + } + + // Statistics + if (data.statistics.databaseSizeBytes !== null) { + addMetric('trilium_database_size_bytes', data.statistics.databaseSizeBytes, 'Database size in bytes'); + } + + if (data.statistics.oldestNote) { + const oldestTimestamp = Math.floor(new Date(data.statistics.oldestNote).getTime() / 1000); + addMetric('trilium_oldest_note_timestamp', oldestTimestamp, 'Timestamp of the oldest note'); + } + + if (data.statistics.newestNote) { + const newestTimestamp = Math.floor(new Date(data.statistics.newestNote).getTime() / 1000); + addMetric('trilium_newest_note_timestamp', newestTimestamp, 'Timestamp of the newest note'); + } + + if (data.statistics.lastModified) { + const lastModifiedTimestamp = Math.floor(new Date(data.statistics.lastModified).getTime() / 1000); + addMetric('trilium_last_modified_timestamp', lastModifiedTimestamp, 'Timestamp of the last modification'); + } + + return lines.join('\n'); +} + +/** + * Collects comprehensive metrics about the Trilium instance + */ +function collectMetrics(): MetricsData { + // Version information + const version = { + app: appInfo.appVersion, + db: appInfo.dbVersion, + node: appInfo.nodeVersion, + sync: appInfo.syncVersion, + buildDate: appInfo.buildDate, + buildRevision: appInfo.buildRevision + }; + + // Database counts + const totalNotes = sql.getValue("SELECT COUNT(*) FROM notes"); + const deletedNotes = sql.getValue("SELECT COUNT(*) FROM notes WHERE isDeleted = 1"); + const activeNotes = totalNotes - deletedNotes; + const protectedNotes = sql.getValue("SELECT COUNT(*) FROM notes WHERE isProtected = 1 AND isDeleted = 0"); + + const totalAttachments = sql.getValue("SELECT COUNT(*) FROM attachments"); + const deletedAttachments = sql.getValue("SELECT COUNT(*) FROM attachments WHERE isDeleted = 1"); + const activeAttachments = totalAttachments - deletedAttachments; + + const totalRevisions = sql.getValue("SELECT COUNT(*) FROM revisions"); + const totalBranches = sql.getValue("SELECT COUNT(*) FROM branches WHERE isDeleted = 0"); + const totalAttributes = sql.getValue("SELECT COUNT(*) FROM attributes WHERE isDeleted = 0"); + const totalBlobs = sql.getValue("SELECT COUNT(*) FROM blobs"); + const totalEtapiTokens = sql.getValue("SELECT COUNT(*) FROM etapi_tokens WHERE isDeleted = 0"); + const totalRecentNotes = sql.getValue("SELECT COUNT(*) FROM recent_notes"); + + // Embedding-related metrics (these tables might not exist in older versions) + let totalEmbeddings = 0; + let totalEmbeddingProviders = 0; + try { + totalEmbeddings = sql.getValue("SELECT COUNT(*) FROM note_embeddings"); + totalEmbeddingProviders = sql.getValue("SELECT COUNT(*) FROM embedding_providers"); + } catch (e) { + // Tables don't exist, keep defaults + } + + const database = { + totalNotes, + deletedNotes, + activeNotes, + protectedNotes, + totalAttachments, + deletedAttachments, + activeAttachments, + totalRevisions, + totalBranches, + totalAttributes, + totalBlobs, + totalEtapiTokens, + totalRecentNotes, + totalEmbeddings, + totalEmbeddingProviders + }; + + // Note types breakdown + const noteTypesRows = sql.getRows<{ type: string; count: number }>( + "SELECT type, COUNT(*) as count FROM notes WHERE isDeleted = 0 GROUP BY type ORDER BY count DESC" + ); + const noteTypes: Record = {}; + for (const row of noteTypesRows) { + noteTypes[row.type] = row.count; + } + + // Attachment types breakdown + const attachmentTypesRows = sql.getRows<{ mime: string; count: number }>( + "SELECT mime, COUNT(*) as count FROM attachments WHERE isDeleted = 0 GROUP BY mime ORDER BY count DESC" + ); + const attachmentTypes: Record = {}; + for (const row of attachmentTypesRows) { + attachmentTypes[row.mime] = row.count; + } + + // Statistics + const oldestNote = sql.getValue( + "SELECT utcDateCreated FROM notes WHERE isDeleted = 0 ORDER BY utcDateCreated ASC LIMIT 1" + ); + const newestNote = sql.getValue( + "SELECT utcDateCreated FROM notes WHERE isDeleted = 0 ORDER BY utcDateCreated DESC LIMIT 1" + ); + const lastModified = sql.getValue( + "SELECT utcDateModified FROM notes WHERE isDeleted = 0 ORDER BY utcDateModified DESC LIMIT 1" + ); + + // Database size (this might not work on all systems) + let databaseSizeBytes: number | null = null; + try { + const sizeResult = sql.getValue("SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()"); + databaseSizeBytes = sizeResult; + } catch (e) { + // Pragma might not be available + } + + const statistics = { + oldestNote, + newestNote, + lastModified, + databaseSizeBytes + }; + + return { + version, + database, + noteTypes, + attachmentTypes, + statistics, + timestamp: new Date().toISOString() + }; +} + +function register(router: Router) { + eu.route(router, "get", "/etapi/metrics", (req, res, next) => { + try { + const metrics = collectMetrics(); + const format = (req.query.format as string)?.toLowerCase() || 'prometheus'; + + if (format === 'json') { + res.status(200).json(metrics); + } else if (format === 'prometheus') { + const prometheusText = formatPrometheusMetrics(metrics); + res.status(200) + .set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') + .send(prometheusText); + } else { + throw new eu.EtapiError(400, "INVALID_FORMAT", "Supported formats: 'prometheus' (default), 'json'"); + } + } catch (error: any) { + throw new eu.EtapiError(500, "METRICS_ERROR", `Failed to collect metrics: ${error.message}`); + } + }); +} + +export default { + register, + collectMetrics, + formatPrometheusMetrics +}; diff --git a/apps/server/src/routes/api/metrics.ts b/apps/server/src/routes/api/metrics.ts new file mode 100644 index 000000000..b60b1ddb6 --- /dev/null +++ b/apps/server/src/routes/api/metrics.ts @@ -0,0 +1,168 @@ +import etapiMetrics from "../../etapi/metrics.js"; + +/** + * @swagger + * /api/metrics: + * get: + * summary: Get Trilium instance metrics + * operationId: metrics + * parameters: + * - in: query + * name: format + * schema: + * type: string + * enum: [prometheus, json] + * default: prometheus + * description: Response format - 'prometheus' (default) for Prometheus text format, 'json' for JSON + * responses: + * '200': + * description: Instance metrics + * content: + * text/plain: + * schema: + * type: string + * example: | + * # HELP trilium_info Trilium instance information + * # TYPE trilium_info gauge + * trilium_info{version="0.91.6",db_version="231",node_version="v18.17.0"} 1 1701432000 + * + * # HELP trilium_notes_total Total number of notes including deleted + * # TYPE trilium_notes_total gauge + * trilium_notes_total 1234 1701432000 + * application/json: + * schema: + * type: object + * properties: + * version: + * type: object + * properties: + * app: + * type: string + * example: "0.91.6" + * db: + * type: integer + * example: 231 + * node: + * type: string + * example: "v18.17.0" + * sync: + * type: integer + * example: 35 + * buildDate: + * type: string + * example: "2024-09-07T18:36:34Z" + * buildRevision: + * type: string + * example: "7c0d6930fa8f20d269dcfbcbc8f636a25f6bb9a7" + * database: + * type: object + * properties: + * totalNotes: + * type: integer + * example: 1234 + * deletedNotes: + * type: integer + * example: 56 + * activeNotes: + * type: integer + * example: 1178 + * protectedNotes: + * type: integer + * example: 23 + * totalAttachments: + * type: integer + * example: 89 + * deletedAttachments: + * type: integer + * example: 5 + * activeAttachments: + * type: integer + * example: 84 + * totalRevisions: + * type: integer + * example: 567 + * totalBranches: + * type: integer + * example: 1200 + * totalAttributes: + * type: integer + * example: 345 + * totalBlobs: + * type: integer + * example: 678 + * totalEtapiTokens: + * type: integer + * example: 3 + * totalRecentNotes: + * type: integer + * example: 50 + * totalEmbeddings: + * type: integer + * example: 123 + * totalEmbeddingProviders: + * type: integer + * example: 2 + * noteTypes: + * type: object + * additionalProperties: + * type: integer + * example: + * text: 800 + * code: 200 + * image: 100 + * file: 50 + * attachmentTypes: + * type: object + * additionalProperties: + * type: integer + * example: + * "image/png": 45 + * "image/jpeg": 30 + * "application/pdf": 14 + * statistics: + * type: object + * properties: + * oldestNote: + * type: string + * nullable: true + * example: "2020-01-01T00:00:00.000Z" + * newestNote: + * type: string + * nullable: true + * example: "2024-12-01T12:00:00.000Z" + * lastModified: + * type: string + * nullable: true + * example: "2024-12-01T11:30:00.000Z" + * databaseSizeBytes: + * type: integer + * nullable: true + * example: 52428800 + * timestamp: + * type: string + * example: "2024-12-01T12:00:00.000Z" + * '400': + * description: Invalid format parameter + * '500': + * description: Error collecting metrics + * security: + * - session: [] + */ +function getMetrics(req: any, res: any): any { + const format = (req.query?.format as string)?.toLowerCase() || 'prometheus'; + + if (format === 'json') { + return etapiMetrics.collectMetrics(); + } else if (format === 'prometheus') { + const metrics = etapiMetrics.collectMetrics(); + const prometheusText = etapiMetrics.formatPrometheusMetrics(metrics); + res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); + return prometheusText; + } else { + throw new Error("Supported formats: 'prometheus' (default), 'json'"); + } +} + +export default { + getMetrics +}; diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index 5280381ea..d9ac2c2f8 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -52,6 +52,7 @@ import fontsRoute from "./api/fonts.js"; import etapiTokensApiRoutes from "./api/etapi_tokens.js"; import relationMapApiRoute from "./api/relation-map.js"; import otherRoute from "./api/other.js"; +import metricsRoute from "./api/metrics.js"; import shareRoutes from "../share/routes.js"; import embeddingsRoute from "./api/embeddings.js"; import ollamaRoute from "./api/ollama.js"; @@ -68,6 +69,7 @@ import etapiNoteRoutes from "../etapi/notes.js"; import etapiSpecialNoteRoutes from "../etapi/special_notes.js"; import etapiSpecRoute from "../etapi/spec.js"; import etapiBackupRoute from "../etapi/backup.js"; +import etapiMetricsRoute from "../etapi/metrics.js"; import apiDocsRoute from "./api_docs.js"; import { apiResultHandler, apiRoute, asyncApiRoute, asyncRoute, route, router, uploadMiddlewareWithErrorHandling } from "./route_api.js"; @@ -236,6 +238,7 @@ function register(app: express.Application) { apiRoute(PST, "/api/recent-notes", recentNotesRoute.addRecentNote); apiRoute(GET, "/api/app-info", appInfoRoute.getAppInfo); + apiRoute(GET, "/api/metrics", metricsRoute.getMetrics); // docker health check route(GET, "/api/health-check", [], () => ({ status: "ok" }), apiResultHandler); @@ -363,6 +366,7 @@ function register(app: express.Application) { etapiSpecialNoteRoutes.register(router); etapiSpecRoute.register(router); etapiBackupRoute.register(router); + etapiMetricsRoute.register(router); // LLM Chat API asyncApiRoute(PST, "/api/llm/chat", llmRoute.createSession);