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
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); }
time(label: string): () => number {
const start = performance.now();
return () => {
const ms = performance.now() - start;
this.debug(`${label} (${ms.toFixed(2)}ms)`);
return ms;
};
}
getHistory(count?: number): LogEntry[] {
return count ? this.history.slice(-count) : [...this.history];
}
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
interface TimerStats {
count: number;
total: number;
min: number;
max: number;
values: number[];
}
export class MetricsCollector {
private counters = new Map<string, number>();
private gauges = new Map<string, number>();
private timers = new Map<string, TimerStats>();
increment(name: string, amount = 1) {
this.counters.set(name, (this.counters.get(name) ?? 0) + amount);
}
getCounter(name: string): number {
return this.counters.(name) ?? ;
}
() {
..(name, value);
}
(: ): {
..(name) ?? ;
}
() {
stats = ..(name);
(!stats) {
stats = { : , : , : , : , : [] };
..(name, stats);
}
stats.++;
stats. += ms;
stats. = .(stats., ms);
stats. = .(stats., ms);
stats..(ms);
(stats.. > ) stats..();
}
timeAsync<T>(: , : <T>): <T> {
start = performance.();
{
();
} {
.(name, performance.() - start);
}
}
(: ): { : ; : ; : ; : ; : } | {
stats = ..(name);
(!stats || stats. === ) ;
sorted = [...stats.].( a - b);
p95Index = .(sorted. * );
{
: stats. / stats.,
: sorted[p95Index] ?? sorted[sorted. - ],
: stats.,
: stats.,
: stats.,
};
}
(): <, > {
: <, > = {};
( [k, v] .) result[] = v;
( [k, v] .) result[] = v;
( [k] .) {
s = .(k);
(s) result[] = s;
}
result;
}
}
Step 3: Error Tracker with Deduplication
interface TrackedError {
name: string;
message: string;
stack?: string;
count: number;
firstSeen: number;
lastSeen: number;
}
export class ErrorTracker {
private errors = new Map<string, TrackedError>();
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: ,
: .(),
: .(),
});
}
}
wrapAsync<T>(: , : <T>): <T | > {
{
();
} (e) {
err = e ? e : ((e));
err. = ;
.(err);
;
}
}
(): [] {
[.....()].( b. - a.);
}
(): {
total = ;
( e ..()) total += e.;
total;
}
() { ..(); }
}
Step 4: Debug Sidebar Panel
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 ; }
() { ; }
() { ; }
() {
.();
. = .( .(), );
}
() {
(.) (.);
}
() {
container = ..[];
container.();
container.();
container.(, { : });
snapshot = ..();
metricsTable = container.();
( [key, value] .(snapshot)) {
row = metricsTable.();
row.(, { : key, : });
row.(, {
: value === ? .(value) : (value),
});
}
errors = ..();
container.(, { : });
(errors. === ) {
container.(, { : , : });
} {
( err errors.(, )) {
el = container.(, { : });
el.(, { : });
el.(, { : err. });
}
}
container.(, { : });
logs = ..();
( entry logs.()) {
el = container.(, { : });
time = (entry.).();
el.(, { : , : });
el.(, { : });
}
btn = container.(, { : });
btn.(, {
bundle = {
: ().(),
: snapshot,
: errors,
: ..(),
};
navigator..(.(bundle, , ));
(().)();
});
}
}
Step 5: Wire Everything into the Plugin
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 ();
.(,
(leaf, ., ., .)
);
.({
: ,
: ,
: .(),
});
.({
: ,
: ,
: () => {
..();
endTimer = ..();
..(, () => {
});
ms = ();
..(, ms);
},
});
.(
.( {
..(, ...().);
(performance.) {
..(,
.((performance ).. / ));
}
}, )
);
..();
}
() {
...();
}
() {
{ workspace } = .;
leaf = workspace.()[];
(!leaf) {
rightLeaf = workspace.();
(rightLeaf) {
rightLeaf.({ : , : });
leaf = rightLeaf;
}
}
(leaf) workspace.(leaf);
}
}
Step 6: CSS for the Debug Panel
.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; }
{ : (--text-faint); }
{ : (--text-muted); }
{ : (--text-normal); }
{ : (--text-accent); }
{ : (--text-error); }
{ : (--text-faint); : 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();
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.