Skip to main content Set up comprehensive logging and monitoring for Obsidian plugins.
Use when implementing debug logging, tracking plugin performance,
or setting up error reporting for your Obsidian plugin.
Trigger with phrases like "obsidian logging", "obsidian monitoring",
"obsidian debug", "track obsidian plugin".
npx skills add jeremylongshore/claude-code-plugins-plus-skills --skill obsidian-observability ai automation claude-code devops mcp ai-agents
Obsidian Observability
Overview
Implement production observability for Obsidian plugins: a structured logger with levels and ring buffer history, a metrics collector with counters/gauges/timers, an error tracker with deduplication, and a debug sidebar panel that displays all of it in real time. Every component is copy-pasteable and uses only Obsidian's built-in APIs.
Prerequisites
Working Obsidian plugin (see obsidian-core-workflow-a)
TypeScript strict mode enabled
Familiarity with ItemView for the debug panel
Instructions
Step 1: Structured Logger with Levels and History
// src/services/logger.ts
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 0, info: 1, warn: 2, error: 3,
};
interface LogEntry {
timestamp: number;
level: LogLevel;
message: string;
data?: unknown;
}
export class Logger {
private history: LogEntry[] = [];
private maxHistory = 200;
private level: LogLevel;
private prefix: string;
constructor(pluginId: string, level: LogLevel = 'info') {
this.prefix = `[${pluginId}]`;
this.level = level;
}
setLevel(level: LogLevel) { this.level = level; }
debug(msg: string, data?: unknown) { this.log('debug', msg, data); }
info(msg: string, data?: unknown) { this.log('info', msg, data); }
warn(msg: string, data?: unknown) { this.log('warn', msg, data); }
error(msg: string, data?: unknown) { this.log('error', msg, data); }
/** Start a timer, returns a function that stops it and logs duration */
time(label: string): () => number {
const start = performance.now();
return () => {
const ms = performance.now() - start;
this.debug(`${label} (${ms.toFixed(2)}ms)`);
return ms;
};
}
/** Get last N log entries */
getHistory(count?: number): LogEntry[] {
return count ? this.history.slice(-count) : [...this.history];
}
/** Export history as JSON string */
export(): string {
return JSON.stringify(this.history, null, 2);
}
private log(level: LogLevel, message: string, data?: unknown) {
if (LEVEL_PRIORITY[level] < LEVEL_PRIORITY[this.level]) return;
const entry: LogEntry = { timestamp: Date.now(), level, message, data };
this.history.push(entry);
if (this.history.length > this.maxHistory) {
this.history.splice(0, this.history.length - this.maxHistory);
}
const fn = level === 'debug' ? console.debug
: level === 'warn' ? console.warn
: level === 'error' ? console.error
: console.log;
if (data !== undefined) {
fn(this.prefix, message, data);
} else {
fn(this.prefix, message);
}
}
}
Step 2: Metrics Collector with Counters, Gauges, and Timers // src/services/metrics.ts
interface TimerStats {
count: number;
total: number;
min: number;
max: number;
values: number[]; // last 100 values for percentile calculation
}
export class MetricsCollector {
private counters = new Map<string, number>();
private gauges = new Map<string, number>();
private timers = new Map<string, TimerStats>();
// Counters — monotonically increasing
increment(name: string, amount = 1) {
this.counters.set(name, (this.counters.get(name) ?? 0) + amount);
}
getCounter(name: string): number {
return this.counters.get(name) ?? 0;
}
// Gauges — point-in-time values
setGauge(name: string, value: number) {
this.gauges.set(name, value);
}
getGauge(name: string): number {
return this.gauges.get(name) ?? 0;
}
// Timers — track duration distributions
recordTime(name: string, ms: number) {
let stats = this.timers.get(name);
if (!stats) {
stats = { count: 0, total: 0, min: Infinity, max: 0, values: [] };
this.timers.set(name, stats);
}
stats.count++;
stats.total += ms;
stats.min = Math.min(stats.min, ms);
stats.max = Math.max(stats.max, ms);
stats.values.push(ms);
if (stats.values.length > 100) stats.values.shift();
}
/** Wrap an async function with automatic timing */
async timeAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
const start = performance.now();
try {
return await fn();
} finally {
this.recordTime(name, performance.now() - start);
}
}
getTimerStats(name: string): { avg: number; p95: number; min: number; max: number; count: number } | null {
const stats = this.timers.get(name);
if (!stats || stats.count === 0) return null;
const sorted = [...stats.values].sort((a, b) => a - b);
const p95Index = Math.floor(sorted.length * 0.95);
return {
avg: stats.total / stats.count,
p95: sorted[p95Index] ?? sorted[sorted.length - 1],
min: stats.min,
max: stats.max,
count: stats.count,
};
}
/** Get all metrics as a plain object for serialization */
snapshot(): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [k, v] of this.counters) result[`counter.${k}`] = v;
for (const [k, v] of this.gauges) result[`gauge.${k}`] = v;
for (const [k] of this.timers) {
const s = this.getTimerStats(k);
if (s) result[`timer.${k}`] = s;
}
return result;
}
}
Step 3: Error Tracker with Deduplication // src/services/error-tracker.ts
interface TrackedError {
name: string;
message: string;
stack?: string;
count: number;
firstSeen: number;
lastSeen: number;
}
export class ErrorTracker {
private errors = new Map<string, TrackedError>();
/** Record an error, deduplicating by name+message */
track(err: Error) {
const key = `${err.name}:${err.message}`;
const existing = this.errors.get(key);
if (existing) {
existing.count++;
existing.lastSeen = Date.now();
} else {
this.errors.set(key, {
name: err.name,
message: err.message,
stack: err.stack,
count: 1,
firstSeen: Date.now(),
lastSeen: Date.now(),
});
}
}
/** Wrap an async function — catch and track errors, then rethrow */
async wrapAsync<T>(label: string, fn: () => Promise<T>): Promise<T | undefined> {
try {
return await fn();
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
err.message = `[${label}] ${err.message}`;
this.track(err);
return undefined;
}
}
getErrors(): TrackedError[] {
return [...this.errors.values()].sort((a, b) => b.lastSeen - a.lastSeen);
}
getErrorCount(): number {
let total = 0;
for (const e of this.errors.values()) total += e.count;
return total;
}
clear() { this.errors.clear(); }
}
Step 4: Debug Sidebar Panel // src/views/debug-view.ts
import { ItemView, WorkspaceLeaf } from 'obsidian';
import type { Logger } from '../services/logger';
import type { MetricsCollector } from '../services/metrics';
import type { ErrorTracker } from '../services/error-tracker';
export const DEBUG_VIEW_TYPE = 'plugin-debug-view';
export class DebugView extends ItemView {
private refreshTimer: number | null = null;
constructor(
leaf: WorkspaceLeaf,
private logger: Logger,
private metrics: MetricsCollector,
private errorTracker: ErrorTracker,
) {
super(leaf);
}
getViewType() { return DEBUG_VIEW_TYPE; }
getDisplayText() { return 'Plugin Debug'; }
getIcon() { return 'bug'; }
async onOpen() {
this.render();
// Auto-refresh every 3 seconds
this.refreshTimer = window.setInterval(() => this.render(), 3000);
}
async onClose() {
if (this.refreshTimer) clearInterval(this.refreshTimer);
}
private render() {
const container = this.containerEl.children[1];
container.empty();
container.addClass('plugin-debug-view');
// Metrics section
container.createEl('h4', { text: 'Metrics' });
const snapshot = this.metrics.snapshot();
const metricsTable = container.createEl('table');
for (const [key, value] of Object.entries(snapshot)) {
const row = metricsTable.createEl('tr');
row.createEl('td', { text: key, cls: 'debug-key' });
row.createEl('td', {
text: typeof value === 'object' ? JSON.stringify(value) : String(value),
});
}
// Errors section
const errors = this.errorTracker.getErrors();
container.createEl('h4', { text: `Errors (${this.errorTracker.getErrorCount()})` });
if (errors.length === 0) {
container.createEl('p', { text: 'No errors recorded.', cls: 'debug-empty' });
} else {
for (const err of errors.slice(0, 10)) {
const el = container.createEl('div', { cls: 'debug-error' });
el.createEl('strong', { text: `${err.name} (x${err.count})` });
el.createEl('p', { text: err.message });
}
}
// Recent logs section
container.createEl('h4', { text: 'Recent Logs' });
const logs = this.logger.getHistory(20);
for (const entry of logs.reverse()) {
const el = container.createEl('div', { cls: `debug-log debug-${entry.level}` });
const time = new Date(entry.timestamp).toLocaleTimeString();
el.createEl('span', { text: `${time} [${entry.level}]`, cls: 'debug-time' });
el.createEl('span', { text: ` ${entry.message}` });
}
// Export button
const btn = container.createEl('button', { text: 'Export Debug Bundle' });
btn.addEventListener('click', () => {
const bundle = {
timestamp: new Date().toISOString(),
metrics: snapshot,
errors: errors,
recentLogs: this.logger.getHistory(50),
};
navigator.clipboard.writeText(JSON.stringify(bundle, null, 2));
new (require('obsidian').Notice)('Debug bundle copied to clipboard');
});
}
}
Step 5: Wire Everything into the Plugin // src/main.ts
import { Plugin } from 'obsidian';
import { Logger } from './services/logger';
import { MetricsCollector } from './services/metrics';
import { ErrorTracker } from './services/error-tracker';
import { DebugView, DEBUG_VIEW_TYPE } from './views/debug-view';
export default class MyPlugin extends Plugin {
logger: Logger;
metrics: MetricsCollector;
errors: ErrorTracker;
async onload() {
this.logger = new Logger(this.manifest.id, 'debug');
this.metrics = new MetricsCollector();
this.errors = new ErrorTracker();
// Register debug view (dev mode only — gate behind a setting if desired)
this.registerView(DEBUG_VIEW_TYPE, (leaf) =>
new DebugView(leaf, this.logger, this.metrics, this.errors)
);
this.addCommand({
id: 'open-debug-panel',
name: 'Open debug panel',
callback: () => this.openDebugPanel(),
});
// Track command executions
this.addCommand({
id: 'my-command',
name: 'My Command',
callback: async () => {
this.metrics.increment('commands.my-command');
const endTimer = this.logger.time('my-command');
await this.errors.wrapAsync('my-command', async () => {
// ... your command logic
});
const ms = endTimer();
this.metrics.recordTime('commands.my-command', ms);
},
});
// Track vault stats as gauges
this.registerInterval(
window.setInterval(() => {
this.metrics.setGauge('vault.files', this.app.vault.getMarkdownFiles().length);
if (performance.memory) {
this.metrics.setGauge('memory.usedMB',
Math.round((performance as any).memory.usedJSHeapSize / 1048576));
}
}, 10_000)
);
this.logger.info('Plugin loaded');
}
onunload() {
this.app.workspace.detachLeavesOfType(DEBUG_VIEW_TYPE);
}
private async openDebugPanel() {
const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(DEBUG_VIEW_TYPE)[0];
if (!leaf) {
const rightLeaf = workspace.getRightLeaf(false);
if (rightLeaf) {
await rightLeaf.setViewState({ type: DEBUG_VIEW_TYPE, active: true });
leaf = rightLeaf;
}
}
if (leaf) workspace.revealLeaf(leaf);
}
}
Step 6: CSS for the Debug Panel /* styles.css — add these rules */
.plugin-debug-view { padding: 8px 12px; font-size: var(--font-ui-small); }
.plugin-debug-view h4 { margin: 12px 0 4px; color: var(--text-accent); }
.plugin-debug-view table { width: 100%; border-collapse: collapse; }
.plugin-debug-view td { padding: 2px 6px; border-bottom: 1px solid var(--background-modifier-border); }
.debug-key { font-family: var(--font-monospace); color: var(--text-muted); }
.debug-error { padding: 4px; margin: 4px 0; background: var(--background-modifier-error); border-radius: var(--radius-s); }
.debug-log { padding: 1px 0; font-family: var(--font-monospace); font-size: 11px; }
.debug-time { color: var(--text-faint); }
.debug-debug { color: var(--text-muted); }
.debug-info { color: var(--text-normal); }
.debug-warn { color: var(--text-accent); }
.debug-error { color: var(--text-error); }
.debug-empty { color: var(--text-faint); font-style: italic; }
Output
Structured logger with debug/info/warn/error levels and ring buffer history (200 entries)
Metrics collector: counters (monotonic), gauges (point-in-time), timers (with p95)
Error tracker with deduplication by name+message, occurrence count, and timestamps
Debug sidebar panel that auto-refreshes every 3 seconds showing metrics, errors, and logs
Export button that copies a full debug bundle to clipboard as JSON
Memory and vault stats tracked as gauges every 10 seconds
Error Handling Issue Cause Solution Too much logging output Debug level in production Set level to 'error' or 'warn' for release builds Memory growth from log history Unbounded buffer Ring buffer capped at 200 entries (adjustable) Performance impact from metrics Synchronous metric recording All operations are O(1) map lookups Debug panel slows Obsidian Rapid DOM updates Panel renders at most once per 3 seconds performance.memory undefinedNot available on all platforms Guard with if (performance.memory) Error tracker misses errors Error not thrown, just logged Use wrapAsync around all async operations
Examples
Quick Timing Check const endTimer = logger.time('vault-scan');
const files = app.vault.getMarkdownFiles();
for (const f of files) await app.vault.cachedRead(f);
endTimer(); // Logs: [plugin-id] vault-scan (245.32ms)
Metrics-Wrapped Command this.addCommand({
id: 'search-notes',
name: 'Search notes',
callback: () => this.metrics.timeAsync('search-notes', async () => {
this.metrics.increment('search.invocations');
const results = await this.search();
this.metrics.setGauge('search.lastResultCount', results.length);
}),
});
Resources
Next Steps For incident response using debug bundles, see obsidian-incident-runbook.
For performance optimization, see obsidian-performance-tuning.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Work with Obsidian vaults (plain Markdown notes) and automate via obsidian-cli.
Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in (single or multi-account), or reading/injecting/running secrets via op.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Work with Obsidian vaults (plain Markdown notes) and automate via obsidian-cli.
Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in (single or multi-account), or reading/injecting/running secrets via op.