diff --git a/src/debug.ts b/src/debug.ts index 494f1aa2..b5ee2518 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -277,3 +277,82 @@ const debug = BUILD_TYPE === "debug"; export function createToast(notifier: INotifier) { return createToastFunction(notifier, debug); } + +// ============================================================================ +// Metrics Integration (for obsidian-metrics plugin) +// ============================================================================ + +import type { + IObsidianMetricsAPI, + MetricInstance, + ObsidianMetricsPlugin, +} from "./types/obsidian-metrics"; + +/** + * Metrics for Relay - uses obsidian-metrics plugin if available, no-ops otherwise. + * + * Uses event-based initialization to handle plugin load order. The obsidian-metrics + * plugin emits 'obsidian-metrics:ready' when loaded, and metric creation is idempotent. + */ +class RelayMetrics { + private dbSize: MetricInstance | null = null; + private compactions: MetricInstance | null = null; + private compactionDuration: MetricInstance | null = null; + + /** + * Initialize metrics from the API. Called when obsidian-metrics becomes available. + * Safe to call multiple times - metric creation is idempotent. + */ + initializeFromAPI(api: IObsidianMetricsAPI): void { + this.dbSize = api.createGauge({ + name: "relay_db_size", + help: "Number of updates stored in IndexedDB per document", + labelNames: ["document"], + }); + this.compactions = api.createCounter({ + name: "relay_compactions_total", + help: "Total compaction operations", + labelNames: ["document"], + }); + this.compactionDuration = api.createHistogram({ + name: "relay_compaction_duration_seconds", + help: "Compaction duration in seconds", + labelNames: ["document"], + buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10], + }); + } + + setDbSize(document: string, count: number): void { + this.dbSize?.labels({ document }).set(count); + } + + recordCompaction(document: string, durationSeconds: number): void { + this.compactions?.labels({ document }).inc(); + this.compactionDuration?.labels({ document }).observe(durationSeconds); + } +} + +/** + * Initialize metrics integration with Obsidian app. + * Sets up event listener for obsidian-metrics:ready and checks if already available. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function initializeMetrics(app: any, registerEvent: (eventRef: any) => void): void { + // Listen for metrics API becoming available (or re-initializing after reload) + registerEvent( + app.workspace.on("obsidian-metrics:ready", (api: IObsidianMetricsAPI) => { + metrics.initializeFromAPI(api); + }) + ); + + // Also try to get it immediately in case metrics plugin loaded first + const metricsPlugin = app.plugins?.plugins?.["obsidian-metrics"] as + | ObsidianMetricsPlugin + | undefined; + if (metricsPlugin?.api) { + metrics.initializeFromAPI(metricsPlugin.api); + } +} + +/** Singleton metrics instance */ +export const metrics = new RelayMetrics(); diff --git a/src/main.ts b/src/main.ts index 386ac662..68c8e930 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,6 +30,7 @@ import { RelayInstances, initializeLogger, flushLogs, + initializeMetrics, } from "./debug"; import { getPatcher, Patcher } from "./Patcher"; import { LiveTokenStore } from "./LiveTokenStore"; @@ -329,6 +330,7 @@ export default class Live extends Plugin { disableConsole: false, // Disable console logging }, ); + initializeMetrics(this.app, (ref) => this.registerEvent(ref)); this.notifier = new ObsidianNotifier(); this.debug = curryLog("[System 3][Relay]", "debug"); diff --git a/src/types/obsidian-metrics.d.ts b/src/types/obsidian-metrics.d.ts new file mode 100644 index 00000000..254d5c27 --- /dev/null +++ b/src/types/obsidian-metrics.d.ts @@ -0,0 +1,149 @@ +/** + * Type declarations for the Obsidian Metrics API + * + * Copy this file into your plugin to get type-safe access to the metrics API. + * + * ## Accessing the API + * + * Access via the plugin instance: + * ```typescript + * const metricsPlugin = this.app.plugins.plugins['obsidian-metrics'] as ObsidianMetricsPlugin | undefined; + * const api = metricsPlugin?.api; + * ``` + * + * ## Handling Plugin Load Order + * + * The metrics plugin emits 'obsidian-metrics:ready' when loaded. Listen for this + * event to handle cases where your plugin loads before obsidian-metrics: + * + * ```typescript + * class MyPlugin extends Plugin { + * private metricsApi: IObsidianMetricsAPI | undefined; + * private documentGauge: MetricInstance | undefined; + * + * async onload() { + * // Listen for metrics API becoming available (or re-initializing after reload) + * this.registerEvent( + * this.app.workspace.on('obsidian-metrics:ready', (api: IObsidianMetricsAPI) => { + * this.initializeMetrics(api); + * }) + * ); + * + * // Also try to get it immediately in case metrics plugin loaded first + * const metricsPlugin = this.app.plugins.plugins['obsidian-metrics'] as ObsidianMetricsPlugin | undefined; + * if (metricsPlugin?.api) { + * this.initializeMetrics(metricsPlugin.api); + * } + * } + * + * private initializeMetrics(api: IObsidianMetricsAPI) { + * this.metricsApi = api; + * // Metric creation is idempotent - safe to call multiple times + * this.documentGauge = api.createGauge({ + * name: 'my_document_size_bytes', + * help: 'Size of documents in bytes', + * labelNames: ['document'] + * }); + * } + * + * updateDocumentSize(doc: string, bytes: number) { + * this.documentGauge?.labels({ document: doc }).set(bytes); + * } + * } + * ``` + * + * ## Key Points + * + * - **Do NOT cache the API or metrics long-term** - they become stale if obsidian-metrics reloads + * - Listen for 'obsidian-metrics:ready' and re-initialize your metrics each time it fires + * - Metric creation is idempotent: calling createGauge() with the same name returns the existing metric + * - It's safe to store metric references within an initialization cycle, but always re-create them + * when 'obsidian-metrics:ready' fires + */ + +export interface MetricLabels { + [key: string]: string; +} + +export interface CounterOptions { + name: string; + help: string; + labelNames?: string[]; +} + +export interface GaugeOptions { + name: string; + help: string; + labelNames?: string[]; +} + +export interface HistogramOptions { + name: string; + help: string; + labelNames?: string[]; + buckets?: number[]; +} + +export interface SummaryOptions { + name: string; + help: string; + labelNames?: string[]; + percentiles?: number[]; + maxAgeSeconds?: number; + ageBuckets?: number; +} + +export interface LabeledMetricInstance { + inc(value?: number): void; + dec(value?: number): void; + set(value: number): void; + observe(value: number): void; + startTimer(): () => void; +} + +export interface MetricInstance { + inc(value?: number, labels?: MetricLabels): void; + dec(value?: number, labels?: MetricLabels): void; + set(value: number, labels?: MetricLabels): void; + observe(value: number, labels?: MetricLabels): void; + startTimer(labels?: MetricLabels): () => void; + labels(labels: MetricLabels): LabeledMetricInstance; +} + +export interface IObsidianMetricsAPI { + // Metric retrieval + getMetric(name: string): MetricInstance | undefined; + getAllMetrics(): Promise; + clearMetric(name: string): boolean; + clearAllMetrics(): void; + + // Metric creation (idempotent - returns existing metric if name matches) + createCounter(options: CounterOptions): MetricInstance; + createGauge(options: GaugeOptions): MetricInstance; + createHistogram(options: HistogramOptions): MetricInstance; + createSummary(options: SummaryOptions): MetricInstance; + + // Convenience methods (create + optional initial value) + counter(name: string, help: string, value?: number): MetricInstance; + gauge(name: string, help: string, value?: number): MetricInstance; + histogram(name: string, help: string, buckets?: number[]): MetricInstance; + summary(name: string, help: string, percentiles?: number[]): MetricInstance; + + // Timing utilities + createTimer(metricName: string): () => number; + measureAsync(metricName: string, fn: () => Promise): Promise; + measureSync(metricName: string, fn: () => T): T; +} + +/** Type for the obsidian-metrics plugin instance */ +export interface ObsidianMetricsPlugin { + api: IObsidianMetricsAPI; +} + +/** Augment Obsidian's workspace events to include our custom event */ +declare module 'obsidian' { + interface Workspace { + on(name: 'obsidian-metrics:ready', callback: (api: IObsidianMetricsAPI) => void): EventRef; + trigger(name: 'obsidian-metrics:ready', api: IObsidianMetricsAPI): void; + } +}