Claude-code-plugins-plus-skills obsidian-observability
install
source · Clone the upstream repo
git clone https://github.com/jeremylongshore/claude-code-plugins-plus-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/jeremylongshore/claude-code-plugins-plus-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/saas-packs/obsidian-pack/skills/obsidian-observability" ~/.claude/skills/jeremylongshore-claude-code-plugins-plus-skills-obsidian-observability && rm -rf "$T"
manifest:
plugins/saas-packs/obsidian-pack/skills/obsidian-observability/SKILL.mdsource content
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
for the debug panelItemView
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 or 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 |
undefined | Not available on all platforms | Guard with |
| Error tracker misses errors | Error not thrown, just logged | Use 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.