mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-07-27 18:12:29 +08:00
feat(server): add metrics endpoint and functionality
This commit is contained in:
parent
a703172549
commit
52fb5fa298
267
apps/server/src/etapi/metrics.ts
Normal file
267
apps/server/src/etapi/metrics.ts
Normal file
@ -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<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) {
|
||||
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
|
||||
};
|
168
apps/server/src/routes/api/metrics.ts
Normal file
168
apps/server/src/routes/api/metrics.ts
Normal file
@ -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
|
||||
};
|
@ -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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user