Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
RelayInstances,
initializeLogger,
flushLogs,
initializeMetrics,
} from "./debug";
import { getPatcher, Patcher } from "./Patcher";
import { LiveTokenStore } from "./LiveTokenStore";
Expand Down Expand Up @@ -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");
Expand Down
149 changes: 149 additions & 0 deletions src/types/obsidian-metrics.d.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
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<T>(metricName: string, fn: () => Promise<T>): Promise<T>;
measureSync<T>(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;
}
}