Creating Plugins
Learn how to create custom logger plugins
Creating Plugins
Plugins are the primary way to extend cenglu's functionality. This guide covers everything you need to know to create powerful custom plugins.
Plugin Interface
A plugin is an object that implements the LoggerPlugin interface:
type LoggerPlugin = {
readonly name: string; // Unique plugin name
readonly order?: number; // Execution order (default: 100)
onInit?(logger: Logger): void; // Called once during logger init
onRecord?(record: LogRecord): LogRecord | null; // Transform/filter records
onFormat?(record: LogRecord, formatted: string): string; // Modify formatted output
onWrite?(record: LogRecord, formatted: string): void; // Post-write hook
onFlush?(): Promise<void> | void; // Flush hook
onClose?(): Promise<void> | void; // Cleanup hook
};Basic Plugin
Let's create a simple plugin that adds a timestamp to all logs:
import type { LoggerPlugin, LogRecord } from "cenglu";
const timestampPlugin: LoggerPlugin = {
name: "timestamp",
order: 50,
onRecord(record: LogRecord): LogRecord {
return {
...record,
context: {
...record.context,
timestamp_iso: new Date(record.time).toISOString(),
},
};
},
};
// Use it
const logger = createLogger({
plugins: [timestampPlugin],
});Plugin Lifecycle Hooks
onInit()
Called once when the logger is initialized. Use for setup.
const setupPlugin: LoggerPlugin = {
name: "setup",
onInit(logger: Logger) {
console.log("Plugin initialized");
// Access logger properties
console.log("Logger level:", logger.getLevel());
// Perform setup
this.connection = setupConnection();
},
// Clean up in onClose
async onClose() {
await this.connection?.close();
},
};onRecord()
Transform or filter log records. Return:
- Modified
LogRecordto transform nullto drop the logundefinedto keep unchanged
const filterPlugin: LoggerPlugin = {
name: "filter",
order: 10, // Run early
onRecord(record: LogRecord): LogRecord | null {
// Drop logs without userId
if (!record.context?.userId) {
return null; // Log is dropped
}
// Add custom field
return {
...record,
context: {
...record.context,
processed: true,
},
};
},
};onFormat()
Modify the formatted string before output.
const uppercasePlugin: LoggerPlugin = {
name: "uppercase",
onFormat(record: LogRecord, formatted: string): string {
// Make all logs uppercase
return formatted.toUpperCase();
},
};onWrite()
Called after a log is written. Use for side effects.
const metricsPlugin: LoggerPlugin = {
name: "metrics",
errorCount: 0,
onWrite(record: LogRecord, formatted: string) {
if (record.level === "error") {
this.errorCount++;
if (this.errorCount % 100 === 0) {
console.warn(`Logged ${this.errorCount} errors so far`);
}
}
},
};onFlush()
Called when logs are flushed. Use for batching or cleanup.
const batchPlugin: LoggerPlugin = {
name: "batch",
buffer: [] as LogRecord[],
onWrite(record: LogRecord) {
this.buffer.push(record);
if (this.buffer.length >= 100) {
this.flush();
}
},
async onFlush() {
if (this.buffer.length === 0) return;
console.log(`Flushing ${this.buffer.length} logs`);
await sendToExternalService(this.buffer);
this.buffer = [];
},
};onClose()
Called when logger is closed. Use for cleanup.
const cleanupPlugin: LoggerPlugin = {
name: "cleanup",
fileHandle: null,
onInit() {
this.fileHandle = fs.openSync("logs.txt", "a");
},
async onClose() {
if (this.fileHandle) {
fs.closeSync(this.fileHandle);
this.fileHandle = null;
}
},
};Plugin Factory Pattern
Create reusable plugins with configuration:
type MyPluginOptions = {
threshold: number;
callback?: (count: number) => void;
};
export function myPlugin(options: MyPluginOptions): LoggerPlugin {
const { threshold, callback } = options;
let count = 0;
return {
name: "my-plugin",
onRecord(record: LogRecord): LogRecord | null {
count++;
if (count >= threshold) {
callback?.(count);
count = 0;
}
return record;
},
};
}
// Usage
const logger = createLogger({
plugins: [
myPlugin({
threshold: 1000,
callback: (count) => console.log(`Processed ${count} logs`),
}),
],
});Real-World Examples
Alerting Plugin
Send alerts when errors exceed threshold:
type AlertPluginOptions = {
errorThreshold: number;
windowMs: number;
onAlert: (count: number) => void;
};
export function alertPlugin(options: AlertPluginOptions): LoggerPlugin {
const { errorThreshold, windowMs, onAlert } = options;
let errorCount = 0;
let windowStart = Date.now();
return {
name: "alert",
order: 90,
onRecord(record: LogRecord): LogRecord {
if (record.level === "error") {
const now = Date.now();
// Reset window
if (now - windowStart >= windowMs) {
errorCount = 0;
windowStart = now;
}
errorCount++;
// Trigger alert
if (errorCount >= errorThreshold) {
onAlert(errorCount);
errorCount = 0; // Reset after alert
}
}
return record;
},
};
}
// Usage
const logger = createLogger({
plugins: [
alertPlugin({
errorThreshold: 10,
windowMs: 60000, // 1 minute
onAlert: (count) => {
sendPagerDutyAlert(`${count} errors in last minute`);
},
}),
],
});Deduplication Plugin
Drop duplicate logs within a time window:
type DedupePluginOptions = {
windowMs?: number;
keyFn?: (record: LogRecord) => string;
};
export function dedupePlugin(options: DedupePluginOptions = {}): LoggerPlugin {
const { windowMs = 60000, keyFn } = options;
const seen = new Map<string, number>();
function getKey(record: LogRecord): string {
if (keyFn) {
return keyFn(record);
}
return `${record.level}:${record.msg}`;
}
function cleanup() {
const now = Date.now();
for (const [key, timestamp] of seen.entries()) {
if (now - timestamp >= windowMs) {
seen.delete(key);
}
}
}
// Cleanup every minute
const interval = setInterval(cleanup, 60000);
return {
name: "dedupe",
order: 15,
onRecord(record: LogRecord): LogRecord | null {
const key = getKey(record);
const now = Date.now();
const lastSeen = seen.get(key);
if (lastSeen && now - lastSeen < windowMs) {
return null; // Drop duplicate
}
seen.set(key, now);
return record;
},
onClose() {
clearInterval(interval);
},
};
}Performance Monitoring Plugin
Track performance metrics for logging:
type PerformancePluginOptions = {
sampleRate?: number;
onReport?: (metrics: PerformanceMetrics) => void;
};
type PerformanceMetrics = {
totalLogs: number;
avgDuration: number;
p95Duration: number;
p99Duration: number;
};
export function performancePlugin(options: PerformancePluginOptions = {}): LoggerPlugin {
const { sampleRate = 1.0, onReport } = options;
const durations: number[] = [];
let totalLogs = 0;
let startTime: number | null = null;
function shouldSample(): boolean {
return Math.random() < sampleRate;
}
function calculateMetrics(): PerformanceMetrics {
const sorted = [...durations].sort((a, b) => a - b);
const sum = sorted.reduce((a, b) => a + b, 0);
return {
totalLogs,
avgDuration: sum / sorted.length,
p95Duration: sorted[Math.floor(sorted.length * 0.95)] || 0,
p99Duration: sorted[Math.floor(sorted.length * 0.99)] || 0,
};
}
// Report metrics every minute
const interval = setInterval(() => {
if (durations.length > 0) {
const metrics = calculateMetrics();
onReport?.(metrics);
console.log("Logging performance:", metrics);
durations.length = 0; // Clear
}
}, 60000);
return {
name: "performance",
order: 5,
onRecord(record: LogRecord): LogRecord {
totalLogs++;
if (shouldSample()) {
startTime = performance.now();
}
return record;
},
onWrite(record: LogRecord) {
if (startTime !== null) {
const duration = performance.now() - startTime;
durations.push(duration);
startTime = null;
}
},
onClose() {
clearInterval(interval);
},
};
}Context Propagation Plugin
Automatically add context from AsyncLocalStorage:
import { AsyncLocalStorage } from "async_hooks";
type ContextStorage = {
userId?: string;
tenantId?: string;
requestId?: string;
[key: string]: unknown;
};
const storage = new AsyncLocalStorage<ContextStorage>();
export function contextPropagationPlugin(): LoggerPlugin {
return {
name: "context-propagation",
order: 20,
onRecord(record: LogRecord): LogRecord {
const context = storage.getStore();
if (!context) {
return record;
}
return {
...record,
context: {
...record.context,
...context,
},
};
},
};
}
// Helper to set context
export function withContext<T>(context: ContextStorage, fn: () => T): T {
return storage.run(context, fn);
}
// Usage
const logger = createLogger({
plugins: [contextPropagationPlugin()],
});
withContext({ userId: "123", requestId: "abc" }, () => {
logger.info("Processing"); // Includes userId and requestId
});Plugin Best Practices
1. Keep Plugins Focused
Each plugin should do one thing well:
// Good: Focused plugin
const errorCountPlugin: LoggerPlugin = {
name: "error-count",
// ... counts errors only
};
// Bad: Does too much
const megaPlugin: LoggerPlugin = {
name: "mega",
// ... counts errors, filters, enriches, formats, etc.
};2. Handle Errors Gracefully
Plugin errors are caught and logged, but don't crash the process:
const safePlugin: LoggerPlugin = {
name: "safe",
onRecord(record: LogRecord): LogRecord {
try {
return transformRecord(record);
} catch (error) {
// Error is caught and logged by cenglu
// Return original record as fallback
return record;
}
},
};3. Minimize Performance Impact
Use early returns and avoid expensive operations:
const fastPlugin: LoggerPlugin = {
name: "fast",
onRecord(record: LogRecord): LogRecord | null {
// Early return for fast path
if (record.level === "trace") {
return null;
}
// Expensive operation only when needed
if (record.context?.needsProcessing) {
return expensiveTransform(record);
}
return record;
},
};4. Clean Up Resources
Always clean up in onClose:
const resourcePlugin: LoggerPlugin = {
name: "resource",
connection: null,
interval: null,
onInit() {
this.connection = createConnection();
this.interval = setInterval(() => this.heartbeat(), 30000);
},
async onClose() {
if (this.interval) {
clearInterval(this.interval);
}
if (this.connection) {
await this.connection.close();
}
},
};5. Use TypeScript
Type your plugins for better developer experience:
type MyPluginOptions = {
threshold: number;
callback: (value: number) => void;
};
export function myPlugin(options: MyPluginOptions): LoggerPlugin {
// TypeScript ensures type safety
const { threshold, callback } = options;
return {
name: "my-plugin",
onRecord(record: LogRecord): LogRecord {
// ...
},
};
}Testing Plugins
Unit Testing
import { describe, expect, it } from "vitest";
import { myPlugin } from "./my-plugin";
describe("myPlugin", () => {
it("should transform records", () => {
const plugin = myPlugin({ threshold: 10 });
const record = {
time: Date.now(),
level: "info" as const,
msg: "test",
};
const result = plugin.onRecord?.(record);
expect(result).toBeDefined();
expect(result?.context?.processed).toBe(true);
});
it("should drop records when threshold exceeded", () => {
const plugin = myPlugin({ threshold: 0 });
const record = {
time: Date.now(),
level: "info" as const,
msg: "test",
};
const result = plugin.onRecord?.(record);
expect(result).toBeNull();
});
});Integration Testing
import { createLogger } from "cenglu";
import { myPlugin } from "./my-plugin";
it("should work with logger", () => {
const logs: any[] = [];
const logger = createLogger({
plugins: [myPlugin({ threshold: 10 })],
adapters: [
{
name: "test",
handle: (record) => logs.push(record),
},
],
});
logger.info("test message");
expect(logs).toHaveLength(1);
expect(logs[0].context?.processed).toBe(true);
});Plugin Distribution
Package Structure
my-cenglu-plugin/
├── src/
│ ├── index.ts
│ └── plugin.ts
├── tests/
│ └── plugin.test.ts
├── package.json
├── tsconfig.json
└── README.mdpackage.json
{
"name": "cenglu-plugin-myplugin",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"keywords": ["cenglu", "logging", "plugin"],
"peerDependencies": {
"cenglu": "^2.0.0"
}
}