import type { Router, Request, Response, NextFunction } 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): void { eu.route(router, "get", "/etapi/metrics", (req: Request, res: Response, next: NextFunction) => { 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: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new eu.EtapiError(500, "METRICS_ERROR", `Failed to collect metrics: ${errorMessage}`); } }); } export default { register, collectMetrics, formatPrometheusMetrics };