Skip to content
obsidian-observability logo

Obsidian Observability

obsidian-observability

Implement structured logging, metrics, error tracking, and a debug panel

SKILL.md

Full skill instructions

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

IssueCauseSolution
Too much logging outputDebug level in productionSet level to 'error' or 'warn' for release builds
Memory growth from log historyUnbounded bufferRing buffer capped at 200 entries (adjustable)
Performance impact from metricsSynchronous metric recordingAll operations are O(1) map lookups
Debug panel slows ObsidianRapid DOM updatesPanel renders at most once per 3 seconds
performance.memory undefinedNot available on all platformsGuard with if (performance.memory)
Error tracker misses errorsError not thrown, just loggedUse 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.

More skills from jeremylongshore

openrouter-compliance-review logo
jeremylongshore/claude-code-plugins-plus-skills

openrouter-compliance-review

Review OpenRouter integration for regulatory compliance (SOC2, GDPR,

2.8K 0
View
maintainx-data-handling logo
jeremylongshore/claude-code-plugins-plus-skills

maintainx-data-handling

Data synchronization, ETL patterns, and data management for MaintainX.

2.8K 0
View
groq-core-workflow-a logo
jeremylongshore/claude-code-plugins-plus-skills

groq-core-workflow-a

Execute Groq primary workflow: chat completions with tool use and JSON

2.8K 0
View
validating-csrf-protection logo
jeremylongshore/claude-code-plugins-plus-skills

validating-csrf-protection

Validate CSRF protection implementations for security gaps. Use when

2.8K 0
View
api-contract logo
jeremylongshore/claude-code-plugins-plus-skills

api-contract

Configure this skill should be used when the user asks about "API contract",

2.8K 0
View
ideogram-incident-runbook logo
jeremylongshore/claude-code-plugins-plus-skills

ideogram-incident-runbook

Execute Ideogram incident response with triage, mitigation, and postmortem.

2.8K 0
View
gamma-sdk-patterns logo
jeremylongshore/claude-code-plugins-plus-skills

gamma-sdk-patterns

Reusable patterns for the Gamma REST API (no SDK exists).

2.8K 0
View
deepgram-sdk-patterns logo
jeremylongshore/claude-code-plugins-plus-skills

deepgram-sdk-patterns

Apply production-ready Deepgram SDK patterns for TypeScript and Python.

2.8K 0
View
openrouter-upgrade-migration logo
jeremylongshore/claude-code-plugins-plus-skills

openrouter-upgrade-migration

Migrate to OpenRouter from direct provider APIs or upgrade between SDK/model

2.8K 0
View
openrouter-common-errors logo
jeremylongshore/claude-code-plugins-plus-skills

openrouter-common-errors

Diagnose and fix common OpenRouter API errors. Use when encountering

2.8K 0
View
linear-cost-tuning logo
jeremylongshore/claude-code-plugins-plus-skills

linear-cost-tuning

Optimize Linear API usage, reduce unnecessary calls, and maximize

2.8K 0
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View