Agent Skills: Obsidian Observability

|

UncategorizedID: jeremylongshore/claude-code-plugins-plus-skills/obsidian-observability

Install this agent skill to your local

pnpm dlx add-skill https://github.com/jeremylongshore/claude-code-plugins-plus-skills/tree/HEAD/plugins/saas-packs/obsidian-pack/skills/obsidian-observability

Skill Files

Browse the full folder contents for obsidian-observability.

Download Skill

Loading file tree…

plugins/saas-packs/obsidian-pack/skills/obsidian-observability/SKILL.md

Skill Metadata

Name
obsidian-observability
Description
|

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 undefined | Not 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.