diff --git a/README.md b/README.md index 22f6b2e..e5fe125 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,21 @@ await baker.saveState(); await baker.restoreState(); ``` +#### Using a Custom Logger + +You can provide a custom logger that implements the `Logger` interface to log messages from Cronbake: + +```typescript +import { getLogger } from '@logtape/logtape'; +import { Baker, FilePersistenceProvider } from 'cronbake'; + +const logger = getLogger(['mirar', 'cron']); + +export const baker = Baker.create({ + logger +}); +``` + #### Persistence Providers (File and Redis) Cronbake uses pluggable providers for persistence. A file provider is included by default. A Redis provider is available; you inject your Redis client. diff --git a/lib/baker.ts b/lib/baker.ts index 18a9ead..939fa4f 100644 --- a/lib/baker.ts +++ b/lib/baker.ts @@ -9,6 +9,7 @@ import { JobMetrics, SchedulerConfig, PersistenceOptions, + Logger, } from "./types"; import { FilePersistenceProvider } from "./persistence/file"; import type { PersistenceProvider } from "./persistence/types"; @@ -27,8 +28,10 @@ class Baker implements IBaker { private persistenceProvider?: PersistenceProvider; private enableMetrics: boolean; private onError?: (error: Error, jobName: string) => void; + private logger: Logger; constructor(options: IBakerOptions = {}) { + this.logger = options.logger ?? console; this.config = { pollingInterval: options.schedulerConfig?.pollingInterval ?? 1000, useCalculatedTimeouts: @@ -65,7 +68,7 @@ class Baker implements IBaker { if (this.persistence.enabled && this.persistence.autoRestore) { const restorePromise = this.restoreState(); restorePromise.catch((err) => { - console.warn("Failed to restore state:", err); + this.logger.warn("Failed to restore state:", err); }); this.initialRestorePromise = restorePromise; } @@ -94,14 +97,19 @@ class Baker implements IBaker { pollingInterval: this.config.pollingInterval, }; + const jobLogger = options.logger ?? this.logger; + const enhancedOptions: CronOptions = { ...options, maxHistory: options.maxHistory ?? this.config.maxHistoryEntries, + logger: jobLogger, onError: options.onError ?? ((error: Error) => { if (this.onError) { this.onError(error, options.name); + } else { + jobLogger.warn(`Cron job '${options.name}' failed:`, error); } }), }; @@ -113,7 +121,7 @@ class Baker implements IBaker { if (this.persistence.enabled && !this.isRestoring) { this.saveState().catch((err) => { - console.warn("Failed to save state:", err); + this.logger.warn("Failed to save state:", err); }); } @@ -130,7 +138,7 @@ class Baker implements IBaker { if (this.persistence.enabled) { this.saveState().catch((err) => { - console.warn("Failed to save state:", err); + this.logger.warn("Failed to save state:", err); }); } } @@ -174,7 +182,7 @@ class Baker implements IBaker { if (this.persistence.enabled) { this.saveState().catch((err) => { - console.warn("Failed to save state:", err); + this.logger.warn("Failed to save state:", err); }); } } @@ -259,7 +267,7 @@ class Baker implements IBaker { if (this.persistence.enabled) { this.saveState().catch((err) => { - console.warn("Failed to save state:", err); + this.logger.warn("Failed to save state:", err); }); } } @@ -314,7 +322,7 @@ class Baker implements IBaker { let restoredCount = 0; for (const jobData of state.jobs) { if (!jobData.name || !jobData.cron) { - console.warn("Skipping invalid job data:", jobData); + this.logger.warn("Skipping invalid job data:", jobData); continue; } if (jobData.persist === false) { @@ -329,7 +337,7 @@ class Baker implements IBaker { name: jobData.name, cron: jobData.cron, callback: () => { - console.warn( + this.logger.warn( `Restored job '${jobData.name}' executed but no callback was provided` ); }, @@ -356,11 +364,11 @@ class Baker implements IBaker { this.restoredJobs.add(jobData.name); restoredCount++; } catch (error) { - console.warn(`Failed to restore job '${jobData.name}':`, error); + this.logger.warn(`Failed to restore job '${jobData.name}':`, error); } } if (restoredCount > 0) { - console.log(`Restored ${restoredCount} cron jobs from persistence`); + this.logger.info(`Restored ${restoredCount} cron jobs from persistence`); } } catch (error) { throw new Error(`Failed to restore state: ${error}`); diff --git a/lib/cron.ts b/lib/cron.ts index 4970d35..4954586 100644 --- a/lib/cron.ts +++ b/lib/cron.ts @@ -7,6 +7,7 @@ import { type Status, type ExecutionHistory, type JobMetrics, + type Logger, } from "./types"; import CronParser from "./parser"; import { CBResolver, resolveIfPromise } from "./utils"; @@ -42,6 +43,7 @@ class Cron implements ICron { private immediate: boolean; private initialDelayMs?: number; private lastRunTime: Date | null = null; + private logger: Logger; /** * Creates a new instance of the `Cron` class. @@ -54,11 +56,12 @@ class Cron implements ICron { throw new Error("Cron job name is required and must be a string"); } + this.logger = options.logger ?? console; this.name = options.name; this.cron = options.cron; this.callback = options.callback; - this.onTick = CBResolver.bind(this, options.onTick); - this.onComplete = CBResolver.bind(this, options.onComplete); + this.onTick = CBResolver.bind(this, options.onTick, undefined, this.logger); + this.onComplete = CBResolver.bind(this, options.onComplete, undefined, this.logger); this.onError = options.onError; this.priority = options.priority ?? 0; this.maxHistory = options.maxHistory ?? 100; @@ -269,10 +272,10 @@ class Cron implements ICron { try { this.onError(err instanceof Error ? err : new Error(String(err))); } catch (handlerError) { - console.warn("Error handler failed:", handlerError); + this.logger.warn("Error handler failed:", handlerError); } } else { - console.warn(`Cron job '${this.name}' failed:`, err); + this.logger.warn(`Cron job '${this.name}' failed:`, err); } this.metrics.lastError = error; } finally { diff --git a/lib/logger.test.ts b/lib/logger.test.ts new file mode 100644 index 0000000..642c9c6 --- /dev/null +++ b/lib/logger.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, jest } from "bun:test"; +import Baker from "./baker"; +import Cron from "./cron"; +import { Logger } from "./types"; + +describe("Custom Logger", () => { + it("should use custom logger in Cron when passed via Baker", async () => { + const mockLogger: Logger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + + const baker = new Baker({ + logger: mockLogger, + }); + + const cron = baker.add({ + name: "test-cron-logger", + cron: "* * * * * *", + callback: () => { + throw new Error("Test error"); + }, + start: true, + immediate: true, + }); + + await new Promise((r) => setTimeout(r, 30)); + + cron.stop(); + baker.destroyAll(); + + // Should have logged a warning + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it("should use custom logger in Cron when passed directly", async () => { + const mockLogger: Logger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + + const cron = new Cron({ + name: "test-cron-direct-logger", + cron: "* * * * * *", + callback: () => { + throw new Error("Test error direct"); + }, + logger: mockLogger, + start: true, + immediate: true, + }); + + await new Promise((r) => setTimeout(r, 30)); + + cron.stop(); + cron.destroy(); + + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it("should allow overriding logger per job in Baker", async () => { + const bakerLogger: Logger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + + const jobLogger: Logger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + + const baker = new Baker({ + logger: bakerLogger, + }); + + const cron = baker.add({ + name: "test-override-logger", + cron: "* * * * * *", + callback: () => { + throw new Error("Test error override"); + }, + logger: jobLogger, + start: true, + immediate: true, + }); + + await new Promise((r) => setTimeout(r, 30)); + + cron.stop(); + baker.destroyAll(); + + expect(jobLogger.warn).toHaveBeenCalled(); + expect(bakerLogger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/types.ts b/lib/types.ts index fadcbcb..9a770e6 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -7,6 +7,16 @@ type CronTime = { dayOfWeek?: number[]; }; +/** + * Interface for a logger that implements standard logging methods. + */ +export interface Logger { + info(message: any, ...args: any[]): void; + error(message: any, ...args: any[]): void; + warn(message: any, ...args: any[]): void; + debug(message: any, ...args: any[]): void; +} + /** * Interface for a cron parser that can parse a cron expression and provide * the next and previous execution times. @@ -272,6 +282,10 @@ type CronOptions = { * If true, run the first callback immediately on start (before schedule) */ immediate?: boolean; + /** + * Custom logger instance + */ + logger?: Logger; /** * If set with `immediate: true`, delay the first run by this amount. * Accepts number (ms) or strings like '500ms', '10s', '2m', '1h'. @@ -485,6 +499,7 @@ interface IBakerOptions { persistence?: PersistenceOptions; enableMetrics?: boolean; onError?: (error: Error, jobName: string) => void; + logger?: Logger; } export { diff --git a/lib/utils.ts b/lib/utils.ts index 937ccf1..d009c1b 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,3 +1,5 @@ +import { Logger } from "./types"; + const resolveIfPromise = async (value: any) => value instanceof Promise ? await value : value; @@ -5,10 +7,12 @@ const resolveIfPromise = async (value: any) => * Resolves a callback function, handling both sync and async functions * @param callback - The callback function to execute * @param onError - Optional error handler + * @param logger - Optional logger */ const CBResolver = async ( callback?: () => void | Promise, - onError?: (error: Error) => void + onError?: (error: Error) => void, + logger: Logger = console ) => { try { if (callback) { @@ -18,7 +22,7 @@ const CBResolver = async ( if (onError) { onError(error as Error); } else { - console.warn('Callback execution failed:', error); + logger.warn('Callback execution failed:', error); } } };