返回 Skill 列表
extension
分类: 开发与工程无需 API Key

obsidian-observability

为Obsidian插件设置全面的日志记录和监控。在实现调试日志、跟踪插件性能或为您的Obsidian插件设置错误报告时使用。可以通过诸如“obsidian logging”、“obsidian monitoring”、“obsidian debug”、“track obsidian plugin”等短语触发。

person作者: jakexiaohubgithub

Obsidian Observability

Overview

Implement comprehensive logging, monitoring, and debugging capabilities for Obsidian plugins.

Prerequisites

  • Working Obsidian plugin
  • Understanding of Developer Tools
  • Basic TypeScript knowledge

Observability Components

Key Metrics

| Metric | Type | Purpose | |--------|------|---------| | Command execution time | Timer | Performance | | File operations count | Counter | Usage patterns | | Error rate | Counter | Reliability | | Cache hit ratio | Gauge | Efficiency | | Memory usage | Gauge | Resource health |

Instructions

Step 1: Structured Logger

// src/utils/logger.ts
type LogLevel = 'debug' | 'info' | 'warn' | 'error';

interface LogEntry {
  timestamp: string;
  level: LogLevel;
  message: string;
  context?: Record<string, any>;
  duration?: number;
}

export class Logger {
  private pluginId: string;
  private level: LogLevel;
  private history: LogEntry[] = [];
  private maxHistory: number = 100;

  private readonly levelPriority: Record<LogLevel, number> = {
    debug: 0,
    info: 1,
    warn: 2,
    error: 3,
  };

  constructor(pluginId: string, level: LogLevel = 'info') {
    this.pluginId = pluginId;
    this.level = level;
  }

  setLevel(level: LogLevel): void {
    this.level = level;
  }

  private shouldLog(level: LogLevel): boolean {
    return this.levelPriority[level] >= this.levelPriority[this.level];
  }

  private formatMessage(entry: LogEntry): string {
    const prefix = `[${this.pluginId}]`;
    const time = entry.timestamp.split('T')[1].split('.')[0];
    const level = entry.level.toUpperCase().padEnd(5);

    let message = `${prefix} ${time} ${level} ${entry.message}`;

    if (entry.duration !== undefined) {
      message += ` (${entry.duration.toFixed(2)}ms)`;
    }

    return message;
  }

  private log(level: LogLevel, message: string, context?: Record<string, any>): void {
    if (!this.shouldLog(level)) return;

    const entry: LogEntry = {
      timestamp: new Date().toISOString(),
      level,
      message,
      context,
    };

    // Store in history
    this.history.push(entry);
    if (this.history.length > this.maxHistory) {
      this.history.shift();
    }

    // Console output
    const formatted = this.formatMessage(entry);
    const consoleMethod = level === 'debug' ? 'log' : level;

    if (context) {
      console[consoleMethod](formatted, context);
    } else {
      console[consoleMethod](formatted);
    }
  }

  debug(message: string, context?: Record<string, any>): void {
    this.log('debug', message, context);
  }

  info(message: string, context?: Record<string, any>): void {
    this.log('info', message, context);
  }

  warn(message: string, context?: Record<string, any>): void {
    this.log('warn', message, context);
  }

  error(message: string, error?: Error, context?: Record<string, any>): void {
    const errorContext = error ? {
      ...context,
      error: {
        name: error.name,
        message: error.message,
        stack: error.stack,
      },
    } : context;

    this.log('error', message, errorContext);
  }

  // Timing helper
  time(label: string): () => void {
    const start = performance.now();
    return () => {
      const duration = performance.now() - start;
      const entry: LogEntry = {
        timestamp: new Date().toISOString(),
        level: 'debug',
        message: label,
        duration,
      };

      if (this.shouldLog('debug')) {
        console.log(this.formatMessage(entry));
      }

      this.history.push(entry);
    };
  }

  // Get log history for debugging
  getHistory(): LogEntry[] {
    return [...this.history];
  }

  // Export logs for support
  exportLogs(): string {
    return this.history.map(entry => {
      let line = `${entry.timestamp} [${entry.level}] ${entry.message}`;
      if (entry.duration) line += ` (${entry.duration}ms)`;
      if (entry.context) line += `\n  ${JSON.stringify(entry.context)}`;
      return line;
    }).join('\n');
  }
}

Step 2: Metrics Collector

// src/utils/metrics.ts
interface MetricValue {
  value: number;
  timestamp: number;
}

export class MetricsCollector {
  private counters = new Map<string, number>();
  private gauges = new Map<string, number>();
  private timers = new Map<string, MetricValue[]>();
  private maxTimerHistory = 100;

  // Counter operations
  increment(name: string, value: number = 1): void {
    const current = this.counters.get(name) || 0;
    this.counters.set(name, current + value);
  }

  getCounter(name: string): number {
    return this.counters.get(name) || 0;
  }

  // Gauge operations
  setGauge(name: string, value: number): void {
    this.gauges.set(name, value);
  }

  getGauge(name: string): number {
    return this.gauges.get(name) || 0;
  }

  // Timer operations
  recordTiming(name: string, durationMs: number): void {
    const timings = this.timers.get(name) || [];
    timings.push({ value: durationMs, timestamp: Date.now() });

    // Keep only recent timings
    while (timings.length > this.maxTimerHistory) {
      timings.shift();
    }

    this.timers.set(name, timings);
  }

  getTimerStats(name: string): {
    count: number;
    avg: number;
    min: number;
    max: number;
    p95: number;
  } | null {
    const timings = this.timers.get(name);
    if (!timings || timings.length === 0) return null;

    const values = timings.map(t => t.value).sort((a, b) => a - b);
    const sum = values.reduce((a, b) => a + b, 0);

    return {
      count: values.length,
      avg: sum / values.length,
      min: values[0],
      max: values[values.length - 1],
      p95: values[Math.floor(values.length * 0.95)],
    };
  }

  // Time a function
  async timeAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
    const start = performance.now();
    try {
      return await fn();
    } finally {
      this.recordTiming(name, performance.now() - start);
    }
  }

  timeSync<T>(name: string, fn: () => T): T {
    const start = performance.now();
    try {
      return fn();
    } finally {
      this.recordTiming(name, performance.now() - start);
    }
  }

  // Get all metrics
  getAllMetrics(): {
    counters: Record<string, number>;
    gauges: Record<string, number>;
    timers: Record<string, ReturnType<typeof this.getTimerStats>>;
  } {
    const timerStats: Record<string, ReturnType<typeof this.getTimerStats>> = {};
    for (const name of this.timers.keys()) {
      timerStats[name] = this.getTimerStats(name);
    }

    return {
      counters: Object.fromEntries(this.counters),
      gauges: Object.fromEntries(this.gauges),
      timers: timerStats,
    };
  }

  // Reset all metrics
  reset(): void {
    this.counters.clear();
    this.gauges.clear();
    this.timers.clear();
  }
}

Step 3: Error Tracking

// src/utils/error-tracker.ts
interface TrackedError {
  timestamp: string;
  error: {
    name: string;
    message: string;
    stack?: string;
  };
  context: Record<string, any>;
  count: number;
}

export class ErrorTracker {
  private errors = new Map<string, TrackedError>();
  private maxErrors = 50;

  track(error: Error, context: Record<string, any> = {}): void {
    const key = `${error.name}:${error.message}`;
    const existing = this.errors.get(key);

    if (existing) {
      existing.count++;
      existing.timestamp = new Date().toISOString();
      existing.context = { ...existing.context, ...context };
    } else {
      // Enforce max errors limit
      if (this.errors.size >= this.maxErrors) {
        const oldestKey = this.errors.keys().next().value;
        this.errors.delete(oldestKey);
      }

      this.errors.set(key, {
        timestamp: new Date().toISOString(),
        error: {
          name: error.name,
          message: error.message,
          stack: error.stack,
        },
        context,
        count: 1,
      });
    }
  }

  getErrors(): TrackedError[] {
    return Array.from(this.errors.values())
      .sort((a, b) => b.count - a.count);
  }

  getMostCommon(limit: number = 5): TrackedError[] {
    return this.getErrors().slice(0, limit);
  }

  clear(): void {
    this.errors.clear();
  }

  // Safe wrapper for async functions
  wrapAsync<T>(
    fn: () => Promise<T>,
    context: Record<string, any> = {}
  ): Promise<T> {
    return fn().catch((error: Error) => {
      this.track(error, context);
      throw error;
    });
  }

  // Export for debugging
  export(): string {
    return JSON.stringify(this.getErrors(), null, 2);
  }
}

Step 4: Debug Panel View

// src/ui/views/debug-view.ts
import { ItemView, WorkspaceLeaf } from 'obsidian';
import type MyPlugin from '../../main';

export const DEBUG_VIEW_TYPE = 'plugin-debug-view';

export class DebugView extends ItemView {
  private plugin: MyPlugin;
  private refreshInterval: number;

  constructor(leaf: WorkspaceLeaf, plugin: MyPlugin) {
    super(leaf);
    this.plugin = plugin;
  }

  getViewType(): string {
    return DEBUG_VIEW_TYPE;
  }

  getDisplayText(): string {
    return 'Plugin Debug';
  }

  getIcon(): string {
    return 'bug';
  }

  async onOpen() {
    const container = this.containerEl.children[1];
    container.empty();
    container.addClass('plugin-debug-view');

    this.render(container);

    // Auto-refresh every 5 seconds
    this.refreshInterval = window.setInterval(() => {
      this.render(container);
    }, 5000);

    this.registerInterval(this.refreshInterval);
  }

  private render(container: Element) {
    container.empty();

    // Metrics section
    container.createEl('h3', { text: 'Metrics' });
    const metricsDiv = container.createDiv({ cls: 'debug-section' });
    this.renderMetrics(metricsDiv);

    // Errors section
    container.createEl('h3', { text: 'Recent Errors' });
    const errorsDiv = container.createDiv({ cls: 'debug-section' });
    this.renderErrors(errorsDiv);

    // Logs section
    container.createEl('h3', { text: 'Recent Logs' });
    const logsDiv = container.createDiv({ cls: 'debug-section' });
    this.renderLogs(logsDiv);

    // Actions
    const actionsDiv = container.createDiv({ cls: 'debug-actions' });
    this.renderActions(actionsDiv);
  }

  private renderMetrics(container: Element) {
    const metrics = this.plugin.metrics.getAllMetrics();

    // Counters
    container.createEl('h4', { text: 'Counters' });
    const countersList = container.createEl('ul');
    for (const [name, value] of Object.entries(metrics.counters)) {
      countersList.createEl('li', { text: `${name}: ${value}` });
    }

    // Timers
    container.createEl('h4', { text: 'Timers' });
    const timersList = container.createEl('ul');
    for (const [name, stats] of Object.entries(metrics.timers)) {
      if (stats) {
        timersList.createEl('li', {
          text: `${name}: avg=${stats.avg.toFixed(2)}ms, p95=${stats.p95.toFixed(2)}ms (n=${stats.count})`,
        });
      }
    }
  }

  private renderErrors(container: Element) {
    const errors = this.plugin.errorTracker.getMostCommon(5);

    if (errors.length === 0) {
      container.createEl('p', { text: 'No errors recorded', cls: 'muted' });
      return;
    }

    const list = container.createEl('ul');
    for (const error of errors) {
      const item = list.createEl('li');
      item.createEl('strong', { text: `${error.error.name}: ` });
      item.createSpan({ text: error.error.message });
      item.createEl('span', {
        text: ` (${error.count}x)`,
        cls: 'error-count',
      });
    }
  }

  private renderLogs(container: Element) {
    const logs = this.plugin.logger.getHistory().slice(-10).reverse();

    const pre = container.createEl('pre', { cls: 'log-output' });
    for (const log of logs) {
      const line = pre.createEl('div', { cls: `log-${log.level}` });
      line.textContent = `[${log.level}] ${log.message}`;
    }
  }

  private renderActions(container: Element) {
    const exportBtn = container.createEl('button', { text: 'Export Debug Data' });
    exportBtn.addEventListener('click', () => this.exportDebugData());

    const clearBtn = container.createEl('button', { text: 'Clear Data' });
    clearBtn.addEventListener('click', () => {
      this.plugin.metrics.reset();
      this.plugin.errorTracker.clear();
      this.render(this.containerEl.children[1]);
    });
  }

  private exportDebugData() {
    const data = {
      timestamp: new Date().toISOString(),
      plugin: this.plugin.manifest.id,
      version: this.plugin.manifest.version,
      metrics: this.plugin.metrics.getAllMetrics(),
      errors: this.plugin.errorTracker.getErrors(),
      logs: this.plugin.logger.exportLogs(),
    };

    // Create download
    const blob = new Blob([JSON.stringify(data, null, 2)], {
      type: 'application/json',
    });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `${this.plugin.manifest.id}-debug-${Date.now()}.json`;
    a.click();
    URL.revokeObjectURL(url);
  }

  async onClose() {
    if (this.refreshInterval) {
      window.clearInterval(this.refreshInterval);
    }
  }
}

Step 5: Integration in Main Plugin

// src/main.ts
import { Plugin } from 'obsidian';
import { Logger } from './utils/logger';
import { MetricsCollector } from './utils/metrics';
import { ErrorTracker } from './utils/error-tracker';
import { DebugView, DEBUG_VIEW_TYPE } from './ui/views/debug-view';

export default class MyPlugin extends Plugin {
  logger: Logger;
  metrics: MetricsCollector;
  errorTracker: ErrorTracker;

  async onload() {
    // Initialize observability
    this.logger = new Logger(this.manifest.id, 'debug');
    this.metrics = new MetricsCollector();
    this.errorTracker = new ErrorTracker();

    this.logger.info('Plugin loading');
    const endLoadTime = this.logger.time('plugin-load');

    // Register debug view (dev mode only)
    if (process.env.NODE_ENV !== 'production') {
      this.registerView(
        DEBUG_VIEW_TYPE,
        (leaf) => new DebugView(leaf, this)
      );

      this.addCommand({
        id: 'open-debug-view',
        name: 'Open Debug View',
        callback: () => this.openDebugView(),
      });
    }

    // Track command usage
    this.addCommand({
      id: 'my-command',
      name: 'My Command',
      callback: async () => {
        this.metrics.increment('command.my-command');
        await this.metrics.timeAsync('command.my-command.duration', async () => {
          // Command implementation
        });
      },
    });

    endLoadTime();
    this.logger.info('Plugin loaded');
  }

  private async openDebugView() {
    const leaf = this.app.workspace.getRightLeaf(false);
    await leaf.setViewState({ type: DEBUG_VIEW_TYPE, active: true });
    this.app.workspace.revealLeaf(leaf);
  }
}

Output

  • Structured logger with levels and history
  • Metrics collection (counters, gauges, timers)
  • Error tracking with deduplication
  • Debug panel view for runtime inspection
  • Export capability for support

Error Handling

| Issue | Cause | Solution | |-------|-------|----------| | Too much logging | Debug level in prod | Set level to 'error' | | Memory growth | Unbounded history | Limit history size | | Performance impact | Sync logging | Use async logging | | Missing context | No error tracking | Wrap async calls |

Examples

Styles for Debug View

/* styles.css */
.plugin-debug-view {
  padding: 16px;
}

.debug-section {
  margin-bottom: 24px;
  padding: 12px;
  background: var(--background-secondary);
  border-radius: 4px;
}

.log-output {
  font-family: monospace;
  font-size: 12px;
  max-height: 200px;
  overflow-y: auto;
}

.log-debug { color: var(--text-muted); }
.log-info { color: var(--text-normal); }
.log-warn { color: var(--text-warning); }
.log-error { color: var(--text-error); }

.error-count {
  color: var(--text-error);
  font-weight: bold;
}

.debug-actions button {
  margin-right: 8px;
}

Resources

Next Steps

For incident response, see obsidian-incident-runbook.