Notes/apps/server/src/etapi/metrics.ts

269 lines
10 KiB
TypeScript

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<string, number>;
attachmentTypes: Record<string, number>;
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<string, string> = {}) => {
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<number>("SELECT COUNT(*) FROM notes");
const deletedNotes = sql.getValue<number>("SELECT COUNT(*) FROM notes WHERE isDeleted = 1");
const activeNotes = totalNotes - deletedNotes;
const protectedNotes = sql.getValue<number>("SELECT COUNT(*) FROM notes WHERE isProtected = 1 AND isDeleted = 0");
const totalAttachments = sql.getValue<number>("SELECT COUNT(*) FROM attachments");
const deletedAttachments = sql.getValue<number>("SELECT COUNT(*) FROM attachments WHERE isDeleted = 1");
const activeAttachments = totalAttachments - deletedAttachments;
const totalRevisions = sql.getValue<number>("SELECT COUNT(*) FROM revisions");
const totalBranches = sql.getValue<number>("SELECT COUNT(*) FROM branches WHERE isDeleted = 0");
const totalAttributes = sql.getValue<number>("SELECT COUNT(*) FROM attributes WHERE isDeleted = 0");
const totalBlobs = sql.getValue<number>("SELECT COUNT(*) FROM blobs");
const totalEtapiTokens = sql.getValue<number>("SELECT COUNT(*) FROM etapi_tokens WHERE isDeleted = 0");
const totalRecentNotes = sql.getValue<number>("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<number>("SELECT COUNT(*) FROM note_embeddings");
totalEmbeddingProviders = sql.getValue<number>("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<string, number> = {};
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<string, number> = {};
for (const row of attachmentTypesRows) {
attachmentTypes[row.mime] = row.count;
}
// Statistics
const oldestNote = sql.getValue<string | null>(
"SELECT utcDateCreated FROM notes WHERE isDeleted = 0 ORDER BY utcDateCreated ASC LIMIT 1"
);
const newestNote = sql.getValue<string | null>(
"SELECT utcDateCreated FROM notes WHERE isDeleted = 0 ORDER BY utcDateCreated DESC LIMIT 1"
);
const lastModified = sql.getValue<string | null>(
"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<number>("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
};