| name | observability |
| description | Best practices for structured logging, correlation IDs, performance metrics, and debugging in Bun applications using @side-quest/core/logging and LogTape. Use when implementing logging, setting up observability, debugging production issues, tracking performance metrics, adding correlation IDs, configuring subsystem loggers, or working with JSONL log analysis. Covers plugin logger factory, hierarchical categories, log levels, metrics collection, and operational debugging workflows. |
| allowed-tools | Read, Grep, Glob |
Observability
Production-grade logging, metrics, and debugging patterns using @side-quest/core/logging (LogTape + JSONL).
Quick Navigation
Core Concepts
Structured Logging with LogTape
Why LogTape: JSONL format enables programmatic analysis, grep filtering, and long-term storage.
Log Location: ~/.claude/logs/<plugin-name>.jsonl
Key Features:
- JSONL (newline-delimited JSON) for machine parsing
- File rotation (1 MiB default, keeps 5 files)
- Hierarchical categories (plugin → subsystem)
- Tagged template literals for clean syntax
- Automatic timestamp, level, logger metadata
Hierarchical Categories
["para-obsidian"]
["para-obsidian", "inbox"]
["para-obsidian", "inbox", "pdf"]
Benefits:
- Filter logs by subsystem
- Different log levels per category
- Trace operations across subsystems
Correlation IDs
Purpose: Link related log entries across subsystems for request tracing.
import { createCorrelationId } from "@side-quest/core/logging";
const cid = createCorrelationId();
inboxLogger.info`Scan started cid=${cid}`;
pdfLogger.debug`Extracting file cid=${cid}`;
llmLogger.info`Detection complete cid=${cid}`;
executeLogger.info`Note created cid=${cid}`;
Plugin Logger Setup
Factory Pattern
ALWAYS use createPluginLogger() from @side-quest/core/logging:
import {
createCorrelationId,
createPluginLogger,
} from "@side-quest/core/logging";
const {
initLogger,
rootLogger,
getSubsystemLogger,
subsystemLoggers,
logFile,
} = createPluginLogger({
name: "my-plugin",
subsystems: ["scraper", "auth", "api"],
});
export { createCorrelationId, initLogger, logFile, rootLogger };
export const scraperLogger = subsystemLoggers.scraper;
export const authLogger = subsystemLoggers.auth;
export const apiLogger = subsystemLoggers.api;
Parameters:
| Option | Required | Default | Description |
|---|
name | ✅ | — | Plugin name (kebab-case), used for log file |
subsystems | ❌ | [] | Subsystem names for hierarchical logging |
logDir | ❌ | ~/.claude/logs | Log directory path |
logFileName | ❌ | name | Log file name (without extension) |
maxSize | ❌ | 1 MiB | Max file size before rotation |
maxFiles | ❌ | 5 | Number of rotated files to keep |
lowestLevel | ❌ | "debug" | Minimum log level |
Initialization
ALWAYS call initLogger() before logging:
import { initLogger, scraperLogger } from "./logger";
async function main() {
await initLogger();
scraperLogger.info`Application started`;
}
main();
Why:
- Creates log directory if missing
- Configures LogTape sinks and formatters
- Logs initialization event (plugin, logDir, maxSize)
- Safe to call multiple times (only initializes once)
Recommended: Emit a one-time “logger initialized” INFO with
logFile, logDir, and lowestLevel so users can confirm where JSONL is written (especially when overriding to a vault-synced path).
Subsystem Logger Naming
Convention: Match subsystem name to functional area:
| Plugin | Subsystems | Use Cases |
|---|
| cinema-bandit | scraper, pricing, auth, gmail | Functional modules |
| kit | ast, semantic, git | Tool categories |
Anti-pattern: Don't use generic names like "utils", "helpers", "common"
Log Levels & Best Practices
Level Guidelines
pdfLogger.debug`Trying selector attempt=${attempt} selector="${sel}"`;
pdfLogger.debug`Hash calculated file=${filename} hash=${hash} duration=${ms}ms`;
inboxLogger.info`Scan started vault=${vaultPath} cid=${cid}`;
scraperLogger.info`Scrape complete movies=${count} duration=${ms}ms`;
inboxLogger.warn`Skipping already processed: ${filename}`;
llmLogger.warn`LLM detection failed, using fallback confidence`;
executeLogger.error`Failed to create note: ${error.message} cid=${cid}`;
authLogger.error`OAuth token refresh failed: ${error}`;
Logging Patterns
Pattern 1: Operation Tracing (Start → Complete)
const cid = createCorrelationId();
const startTime = Date.now();
scraperLogger.info`Scrape started url=${url} cid=${cid}`;
try {
const result = await scrape(url);
const duration = Date.now() - startTime;
scraperLogger.info`Scrape complete movies=${result.length} duration=${duration}ms cid=${cid}`;
return result;
} catch (error) {
const duration = Date.now() - startTime;
scraperLogger.error`Scrape failed error=${error.message} duration=${duration}ms cid=${cid}`;
throw error;
}
Benefits:
- Trace operation lifecycle
- Measure performance (duration)
- Link failure to operation start (cid)
Pattern 2: Conditional Debug Logging
for (const selector of selectors) {
pdfLogger.debug`Trying selector="${selector}"`;
const element = await page.locator(selector).first();
if (await element.isVisible()) {
pdfLogger.debug`Selector matched="${selector}"`;
return element;
}
}
pdfLogger.warn`No selectors matched - using fallback`;
Why: DEBUG logs can be voluminous; use INFO for successes.
Pattern 3: Structured Logging (Recommended)
Use structured logs with properties object instead of template literals:
inboxLogger.info("Bookmark enrichment started", {
event: "enrichment_started",
sessionCid,
cid,
parentCid,
file: file.filename,
url,
timestamp: new Date().toISOString(),
});
inboxLogger.info`Bookmark enrichment started file=${file.filename} url=${url}`;
Benefits:
- Easier jq queries:
jq 'select(.properties.event == "enrichment_started")'
- Consistent field structure across logs
- Type safety with TypeScript
- Better for automated analysis
Queryable later:
jq 'select(.properties.event == "enrichment_started")' ~/.claude/logs/para-obsidian.jsonl
jq 'select(.properties.template == "invoice")' ~/.claude/logs/para-obsidian.jsonl
jq 'select(.properties.durationMs > 1000)' ~/.claude/logs/para-obsidian.jsonl
Event naming convention:
"scan_started", "scan_complete"
"file_processing_started"
"enrichment_started", "enrichment_ended"
"pdf_extraction_complete"
PII safety: When logs sync to cloud storage, mask or omit sensitive extracted fields (names, amounts, IDs) before logging. Prefer short summaries or hashed values over raw content.
Log Level Convention Table
| Level | Volume | Use Case | Example |
|---|
| DEBUG | High | Detailed diagnostics, loops, retries | Selector attempts, hash calculations |
| INFO | Medium | Milestones, summaries, timing | Scan started/complete, item counts |
| WARN | Low | Degraded operation, edge cases | Skipped files, fallback logic |
| ERROR | Very Low | Operation failures, exceptions | Create failed, validation error |
Correlation ID Patterns
OpenTelemetry-Compatible Three-Tier Hierarchy
Based on W3C Trace Context specification, used in production at para-obsidian:
Example hierarchy:
acdfe223 (Session: para scan, 3.2s)
├─ e400fff2: inbox:scan (parent: acdfe223)
│ ├─ df2b9fd7: inbox:processPdf (parent: e400fff2, session: acdfe223)
│ ├─ eae519bd: enrich:bookmark (parent: e400fff2, session: acdfe223)
│ └─ 45c84df0: inbox:skipFastPath (parent: e400fff2, session: acdfe223)
Pattern 1: Session-Level Tracking (Recommended)
Use when: You have a top-level operation (CLI command, MCP request) that spawns multiple sub-operations.
interface ProcessFileContext {
sessionCid: string;
parentCid: string;
file: InboxFile;
}
async function scan(options?: ScanOptions) {
const sessionCid = createCorrelationId();
const cid = createCorrelationId();
inboxLogger.info("Scan started", {
event: "scan_started",
sessionCid,
cid,
vault: vaultPath,
timestamp: new Date().toISOString(),
});
for (const file of files) {
await processSingleFile({
sessionCid,
parentCid: cid,
file,
});
}
inboxLogger.info("Scan complete", {
event: "scan_complete",
sessionCid,
cid,
processed: files.length,
durationMs: Date.now() - startTime,
timestamp: new Date().toISOString(),
});
}
async function processSingleFile(ctx: ProcessFileContext) {
const { sessionCid, parentCid, file } = ctx;
const cid = createCorrelationId();
pdfLogger.info("File processing started", {
event: "file_processing_started",
sessionCid,
cid,
parentCid,
file: file.name,
});
await enrichmentPipeline.processFile(file, {
sessionCid,
cid,
parentCid,
});
}
Benefits:
- ✅ End-to-end traceability across async operations
- ✅ Can trace all operations for a session:
grep sessionCid=acdfe223
- ✅ Can see parent-child relationships:
grep parentCid=e400fff2
- ✅ Compatible with OpenTelemetry for future integration
Pattern 2: Per-Item Tracking (Legacy, Not Recommended)
Use when: Simple scripts without session concept.
for (const file of files) {
const itemCid = createCorrelationId();
try {
pdfLogger.info`Processing file=${file} cid=${itemCid}`;
const text = await extractPdf(file, itemCid);
const type = await detectType(text, itemCid);
pdfLogger.info`Processed file=${file} type=${type} cid=${itemCid}`;
} catch (error) {
pdfLogger.error`Failed file=${file} error=${error.message} cid=${itemCid}`;
}
}
Limitation: Cannot trace operations back to originating session.
Grep Debugging Workflow (Three-Tier IDs)
grep 'sessionCid=acdfe223' ~/.claude/logs/para-obsidian.jsonl
grep 'parentCid=e400fff2' ~/.claude/logs/para-obsidian.jsonl
grep -E '(cid=e400fff2|parentCid=e400fff2)' ~/.claude/logs/para-obsidian.jsonl
grep 'sessionCid=acdfe223' ~/.claude/logs/para-obsidian.jsonl | \
jq -r '."@timestamp" + " | " + (.properties.cid // "N/A") + " | " + (.properties.parentCid // "N/A") + " | " + .message'
grep 'sessionCid=acdfe223' ~/.claude/logs/para-obsidian.jsonl | \
jq -s 'group_by(.properties.parentCid) |
map({parent: .[0].properties.parentCid, children: map(.properties.cid)})'
Legacy single-CID workflow:
grep 'cid=a1b2c3d4' ~/.claude/logs/my-plugin.jsonl
grep 'cid=a1b2c3d4' ~/.claude/logs/my-plugin.jsonl | jq .
grep 'cid=a1b2c3d4' ~/.claude/logs/my-plugin.jsonl | jq -r '.message'
grep 'cid=a1b2c3d4' ~/.claude/logs/my-plugin.jsonl | \
jq -r '."@timestamp" + " | " + .level + " | " + .message'
Performance Metrics
Automatic Metrics Collection
MetricsCollector aggregates durationMs from logs automatically:
import { MetricsCollector } from "@side-quest/core/logging";
const collector = new MetricsCollector();
await collector.collect();
const summary = collector.getSummary();
console.log(summary.toMarkdown());
Include summaries in CLI/JSON output: After a run, return or print { metrics: summary, logFile } so users and automation can locate performance data and the corresponding JSONL file.
Output:
═══════════════════════════════════════════════════════════════
📊 MCP Performance Metrics - Session Summary
═══════════════════════════════════════════════════════════════
Total Operations: 150
Successful: 145 (96.7%)
Failed: 5 (3.3%)
Total Duration: 45.2s
───────────────────────────────────────────────────────────────
Tool Name Calls Avg Time Min Max Errors
───────────────────────────────────────────────────────────────
kit_index_find 25 12ms 8ms 45ms 0
bun_runTests 10 1250ms 980ms 1.5s 2
biome_lintCheck 8 180ms 120ms 250ms 0
───────────────────────────────────────────────────────────────
Slowest Operations:
1. bun_runTests (1.5s)
2. semantic_search (980ms)
...
Manual Metrics Logging
For custom metrics, log durationMs property:
const startTime = Date.now();
try {
const result = await operation();
const durationMs = Date.now() - startTime;
scraperLogger.info`Operation complete`, {
tool: "scraper_fetch",
durationMs,
success: true,
cid,
};
return result;
} catch (error) {
const durationMs = Date.now() - startTime;
scraperLogger.error`Operation failed`, {
tool: "scraper_fetch",
durationMs,
success: false,
cid,
};
throw error;
}
MetricsCollector automatically aggregates:
- Call counts
- Min/max/avg latency
- Error rates
- Success rates
Querying Metrics Manually
jq 'select(.properties.tool == "kit_index_find") | .properties.durationMs' \
~/.claude/logs/kit.jsonl | \
awk '{sum+=$1; count++} END {print sum/count " ms"}'
jq 'select(.properties.tool == "bun_runTests") | .properties.success' \
~/.claude/logs/bun-runner.jsonl | \
sort | uniq -c
jq 'select(.properties.durationMs > 1000)' ~/.claude/logs/*.jsonl
Debugging Workflows
Common Grep Patterns
grep '"level":"error"' ~/.claude/logs/my-plugin.jsonl | tail -20
grep 'file=invoice-123.pdf' ~/.claude/logs/para-obsidian.jsonl
grep -E '"level":"(warn|error)"' ~/.claude/logs/my-plugin.jsonl
jq 'select(.properties.durationMs > 1000)' ~/.claude/logs/*.jsonl
JQ Analysis Patterns
jq -s 'group_by(.properties.error) |
map({error: .[0].properties.error, count: length})' \
~/.claude/logs/my-plugin.jsonl
jq -s 'group_by(.level) |
map({level: .[0].level, count: length})' \
~/.claude/logs/my-plugin.jsonl
jq -r '."@timestamp" + " | " + .properties.cid + " | " + .message' \
~/.claude/logs/my-plugin.jsonl
Discipline: Ensure every log in the flow carries cid (including skips, hash failures, LLM errors, and execute failures) so traces stay contiguous.
Production Debugging Checklist
When debugging production issues:
Testing
Log Assertions in Tests
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { initLogger, scraperLogger } from "./logger";
describe("Scraper", () => {
beforeEach(async () => {
await initLogger();
});
test("logs scrape started", async () => {
const result = await scrape(url);
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
Recommendation: Test functional behavior, not log output. Logs are for observability, not assertions.
Metrics Validation
test("MetricsCollector aggregates correctly", () => {
const collector = new MetricsCollector();
collector.recordOperation("tool_a", 100, true);
collector.recordOperation("tool_a", 200, true);
collector.recordOperation("tool_b", 50, false);
const summary = collector.getSummary();
expect(summary.totalOperations).toBe(3);
expect(summary.successfulOperations).toBe(2);
expect(summary.failedOperations).toBe(1);
const toolA = summary.toolMetrics.find(m => m.tool === "tool_a");
expect(toolA?.avgDurationMs).toBe(150);
expect(toolA?.successRate).toBe(100);
});
Common Patterns
Pattern 1: Subsystem Logger Per Module
import { scraperLogger, createCorrelationId } from "./logger";
export async function scrapeMovies(url: string) {
const cid = createCorrelationId();
scraperLogger.info`Scrape started url=${url} cid=${cid}`;
scraperLogger.info`Scrape complete count=${movies.length} cid=${cid}`;
return movies;
}
Pattern 2: Fallback Chains with Logging
async function trySelectors(page, selectors) {
for (const selector of selectors) {
pdfLogger.debug`Trying selector="${selector}"`;
const element = await page.locator(selector).first();
if (await element.isVisible()) {
pdfLogger.debug`Selector matched="${selector}"`;
return element;
}
}
pdfLogger.warn`All selectors failed - using default`;
return defaultElement;
}
Pattern 3: Registry Updates with Logging
async function updateRegistry(hash: string, notePath: string, cid: string) {
executeLogger.debug`Updating registry hash=${hash} cid=${cid}`;
try {
await registry.markProcessed({
sourceHash: hash,
processedAt: new Date().toISOString(),
createdNote: notePath,
});
await registry.save();
executeLogger.debug`Registry saved hash=${hash} cid=${cid}`;
} catch (error) {
executeLogger.error`Registry update failed hash=${hash} error=${error.message} cid=${cid}`;
throw error;
}
}
Pattern 4: Performance Tracking
async function processItems(items: Item[], cid: string) {
const overallStart = Date.now();
let successCount = 0;
let failureCount = 0;
for (const item of items) {
const itemStart = Date.now();
try {
await processItem(item, cid);
successCount++;
const duration = Date.now() - itemStart;
executeLogger.debug`Item processed file=${item.name} duration=${duration}ms cid=${cid}`;
} catch (error) {
failureCount++;
const duration = Date.now() - itemStart;
executeLogger.error`Item failed file=${item.name} duration=${duration}ms cid=${cid}`;
}
}
const totalDuration = Date.now() - overallStart;
executeLogger.info`Batch complete success=${successCount} failed=${failureCount} duration=${totalDuration}ms cid=${cid}`;
}
Quick Reference
File Structure
plugins/my-plugin/
├── src/
│ ├── logger.ts # createPluginLogger setup + exports
│ ├── scraper.ts # Uses scraperLogger
│ ├── auth.ts # Uses authLogger
│ └── cli.ts # Calls initLogger()
└── mcp/
└── index.ts # Calls initLogger() in startup
Key Imports
import {
createCorrelationId,
createPluginLogger,
MetricsCollector,
} from "@side-quest/core/logging";
Checklist: Adding Logging to a Plugin
Related Skills
Bun CLI Development
Reference: Bun CLI skill
Use for:
- CLI entry point patterns (where to call
initLogger())
- Error handling with exit codes
- Output formatting (markdown + JSON)
Example integration:
import { initLogger, scraperLogger } from "./logger";
async function main() {
await initLogger();
const { command, flags } = parseArgs(process.argv.slice(2));
scraperLogger.info`CLI started command=${command}`;
}
main();
Bun Runtime Workflows
Reference: Bun Runtime skill
Use for:
- Testing patterns (beforeEach/afterEach for initLogger)
- Async/await best practices (initLogger is async)
- Performance optimization (log levels impact throughput)
FAQ
When should I create a new subsystem logger?
Create a new subsystem when:
- Functional area is distinct (scraping, auth, storage)
- You want independent log filtering (
grep '"logger":"my-plugin.scraper"')
- Module has significant enough volume to justify separation
Don't create subsystems for:
- Utils/helpers (use root logger)
- One-off operations (use root logger)
- Overly granular divisions (too many subsystems = hard to filter)
Should I log in tests?
Generally no. Tests should assert functional behavior, not log output.
Exception: When testing logging infrastructure itself (logger.ts, metrics.ts).
How do I rotate logs manually?
Logs auto-rotate at 1 MiB (default). To force rotation:
mv ~/.claude/logs/my-plugin.jsonl ~/.claude/logs/my-plugin.jsonl.1
What's the difference between correlation ID and request ID?
Correlation ID: 8-character UUID for tracing a single operation across subsystems.
Request ID: Typically longer, used for distributed tracing across services.
For local plugins, correlation IDs are sufficient.
How do I debug "no logs appearing"?
- Verify
initLogger() called: Logs won't appear until initialization
- Check log level: DEBUG logs hidden if lowestLevel > "debug"
- Inspect log file directly:
tail -f ~/.claude/logs/my-plugin.jsonl
- Check LogTape errors: Look for "logtape.meta" ERROR logs
- Verify file permissions: Log directory must be writable
Production Reference Implementation
para-obsidian Plugin
Location: plugins/para-obsidian/
Observability Maturity: 4/5 (Adaptive)
Key Features:
- ✅ OpenTelemetry-compatible three-tier CID hierarchy (sessionCid, cid, parentCid)
- ✅ Structured logging with properties objects across all subsystems
- ✅ End-to-end traceability across async operations (scan → classify → enrich → execute)
- ✅ Full trace correlation with parent-child relationships
- ✅ Subsystem loggers: inbox, pdf, llm, execute, git, enrich, search, templates, frontmatter, fs, lock, tx, cli, classify
Files to Study:
src/shared/logger.ts - Plugin logger setup with 14 subsystems
src/inbox/core/engine.ts - Session-level CID threading through file processing
src/inbox/enrich/strategies/bookmark-strategy.ts - Structured logging in enrichment
OBSERVABILITY_IMPROVEMENTS.md - Complete implementation details and rationale
Example Trace:
{
"@timestamp": "2025-12-20T21:37:26.567Z",
"level": "info",
"logger": "para-obsidian.enrich",
"message": "Bookmark enrichment started",
"properties": {
"event": "enrichment_started",
"sessionCid": "acdfe223",
"cid": "eae519bd",
"parentCid": "e400fff2",
"file": "bookmark-example.md",
"url": "https://example.com"
}
}
Grep Workflow:
grep 'sessionCid=acdfe223' ~/.claude/logs/para-obsidian.jsonl
grep 'sessionCid=acdfe223' ~/.claude/logs/para-obsidian.jsonl | \
jq -r '."@timestamp" + " | " + .properties.cid + " | " + .message'
References:
Last Updated: 2025-12-20
Status: Production Reference Implementation
Related: Bun CLI, Bun Runtime, Inbox Processing Expert