From b75a9826a24ded525a6df2bcd995580db941ad18 Mon Sep 17 00:00:00 2001 From: Tamas Vince Kornel Date: Sun, 7 Dec 2025 21:20:49 +0100 Subject: [PATCH 1/3] feat: implement custom logger support in Baker and Cron classes --- lib/baker.ts | 26 +++++---- lib/cron.ts | 11 ++-- lib/logger.test.ts | 132 +++++++++++++++++++++++++++++++++++++++++++++ lib/types.ts | 15 ++++++ lib/utils.ts | 8 ++- 5 files changed, 177 insertions(+), 15 deletions(-) create mode 100644 lib/logger.test.ts 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..23f1412 --- /dev/null +++ b/lib/logger.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, mock } from "bun:test"; +import Baker from "./baker"; +import Cron from "./cron"; +import { Logger } from "./types"; + +describe("Custom Logger", () => { + it("should use custom logger in Baker", () => { + const mockLogger: Logger = { + info: mock(() => {}), + error: mock(() => {}), + warn: mock(() => {}), + debug: mock(() => {}), + }; + + const baker = new Baker({ + logger: mockLogger, + }); + + // Trigger a log (e.g. by adding a job, though Baker doesn't log much on add) + // Baker logs on restore. + + // Let's try to trigger a warning in Baker. + // destroyAll calls saveState which might fail if persistence is enabled but broken? + // Or we can just check if the logger is passed to Cron. + + const cron = baker.add({ + name: "test-logger", + cron: "* * * * * *", + callback: () => {}, + }); + + // Cron should have the logger. + // We can't easily access private logger property. + // But we can trigger a warning in Cron. + + // Trigger an error in callback without onError handler -> should log warning. + // We need to run it. + }); + + it("should use custom logger in Cron when passed via Baker", async () => { + const mockLogger: Logger = { + info: mock(() => {}), + error: mock(() => {}), + warn: mock(() => {}), + debug: mock(() => {}), + }; + + const baker = new Baker({ + logger: mockLogger, + }); + + const cron = baker.add({ + name: "test-cron-logger", + cron: "* * * * * *", + callback: () => { + throw new Error("Test error"); + }, + start: true, + }); + + // Wait for execution + await new Promise((resolve) => setTimeout(resolve, 1100)); + + cron.stop(); + + // Should have logged a warning + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it("should use custom logger in Cron when passed directly", async () => { + const mockLogger: Logger = { + info: mock(() => {}), + error: mock(() => {}), + warn: mock(() => {}), + debug: mock(() => {}), + }; + + const cron = new Cron({ + name: "test-cron-direct-logger", + cron: "* * * * * *", + callback: () => { + throw new Error("Test error direct"); + }, + logger: mockLogger, + start: true, + }); + + // Wait for execution + await new Promise((resolve) => setTimeout(resolve, 1100)); + + cron.stop(); + + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it("should allow overriding logger per job in Baker", async () => { + const bakerLogger: Logger = { + info: mock(() => {}), + error: mock(() => {}), + warn: mock(() => {}), + debug: mock(() => {}), + }; + + const jobLogger: Logger = { + info: mock(() => {}), + error: mock(() => {}), + warn: mock(() => {}), + debug: mock(() => {}), + }; + + 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, + }); + + await new Promise((resolve) => setTimeout(resolve, 1100)); + + cron.stop(); + + 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); } } }; From 8a78e137d1eef00143a9323958a11051c4f176fe Mon Sep 17 00:00:00 2001 From: Tamas Vince Kornel Date: Sun, 7 Dec 2025 21:25:46 +0100 Subject: [PATCH 2/3] docs: add usage instructions for custom logger in README --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 22f6b2e..3b09e6b 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. From 5bb7b2219c94280fc150f795201a9298caf268d3 Mon Sep 17 00:00:00 2001 From: Tamas Vince Kornel Date: Sun, 7 Dec 2025 21:55:59 +0100 Subject: [PATCH 3/3] fix: update logger initialization in tests to use jest mocks and fix other copilot suggestions --- README.md | 2 +- lib/logger.test.ts | 81 +++++++++++++++------------------------------- 2 files changed, 27 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 3b09e6b..e5fe125 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ You can provide a custom logger that implements the `Logger` interface to log me import { getLogger } from '@logtape/logtape'; import { Baker, FilePersistenceProvider } from 'cronbake'; -const logger = getLogger(['mirar', 'cron']) +const logger = getLogger(['mirar', 'cron']); export const baker = Baker.create({ logger diff --git a/lib/logger.test.ts b/lib/logger.test.ts index 23f1412..642c9c6 100644 --- a/lib/logger.test.ts +++ b/lib/logger.test.ts @@ -1,48 +1,15 @@ -import { describe, it, expect, mock } from "bun:test"; +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 Baker", () => { - const mockLogger: Logger = { - info: mock(() => {}), - error: mock(() => {}), - warn: mock(() => {}), - debug: mock(() => {}), - }; - - const baker = new Baker({ - logger: mockLogger, - }); - - // Trigger a log (e.g. by adding a job, though Baker doesn't log much on add) - // Baker logs on restore. - - // Let's try to trigger a warning in Baker. - // destroyAll calls saveState which might fail if persistence is enabled but broken? - // Or we can just check if the logger is passed to Cron. - - const cron = baker.add({ - name: "test-logger", - cron: "* * * * * *", - callback: () => {}, - }); - - // Cron should have the logger. - // We can't easily access private logger property. - // But we can trigger a warning in Cron. - - // Trigger an error in callback without onError handler -> should log warning. - // We need to run it. - }); - it("should use custom logger in Cron when passed via Baker", async () => { const mockLogger: Logger = { - info: mock(() => {}), - error: mock(() => {}), - warn: mock(() => {}), - debug: mock(() => {}), + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), }; const baker = new Baker({ @@ -56,12 +23,13 @@ describe("Custom Logger", () => { throw new Error("Test error"); }, start: true, + immediate: true, }); - // Wait for execution - await new Promise((resolve) => setTimeout(resolve, 1100)); + await new Promise((r) => setTimeout(r, 30)); cron.stop(); + baker.destroyAll(); // Should have logged a warning expect(mockLogger.warn).toHaveBeenCalled(); @@ -69,10 +37,10 @@ describe("Custom Logger", () => { it("should use custom logger in Cron when passed directly", async () => { const mockLogger: Logger = { - info: mock(() => {}), - error: mock(() => {}), - warn: mock(() => {}), - debug: mock(() => {}), + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), }; const cron = new Cron({ @@ -83,29 +51,30 @@ describe("Custom Logger", () => { }, logger: mockLogger, start: true, + immediate: true, }); - // Wait for execution - await new Promise((resolve) => setTimeout(resolve, 1100)); + 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: mock(() => {}), - error: mock(() => {}), - warn: mock(() => {}), - debug: mock(() => {}), + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), }; const jobLogger: Logger = { - info: mock(() => {}), - error: mock(() => {}), - warn: mock(() => {}), - debug: mock(() => {}), + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), }; const baker = new Baker({ @@ -120,11 +89,13 @@ describe("Custom Logger", () => { }, logger: jobLogger, start: true, + immediate: true, }); - await new Promise((resolve) => setTimeout(resolve, 1100)); + await new Promise((r) => setTimeout(r, 30)); cron.stop(); + baker.destroyAll(); expect(jobLogger.warn).toHaveBeenCalled(); expect(bakerLogger.warn).not.toHaveBeenCalled();