From 3dc941555964ba38669799382917a98abf4e29e8 Mon Sep 17 00:00:00 2001 From: James Kebinger Date: Mon, 27 Oct 2025 10:31:02 -0500 Subject: [PATCH 1/6] feat: add LOG_LEVEL_V2 with getLogLevel() and logger integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements dynamic log level control using LOG_LEVEL_V2 config type. Core Features: - Add getLogLevel(loggerName) method to Reforge and Resolver - Add ConfigType.LogLevelV2 enum value - Add loggerKey option (defaults to 'log-levels.default') - Evaluates log level with context: reforge-sdk-logging.logger-path - Returns DEBUG as default when config not found - No hierarchy traversal (unlike v1 implementation) Logger Integrations (Optional Peer Dependencies): - Pino integration: createPinoLogger(), createPinoHook() - Winston integration: createWinstonLogger(), createWinstonFormat() - Dynamic import with graceful degradation - Auto-skip integration tests when libs not installed - Support pino >=7.0.0, winston >=3.0.0 Tests: - 11 new tests for getLogLevel() functionality - 11 integration tests (auto-skip without pino/winston) - All 529 existing tests pass Documentation: - INTEGRATIONS.md with complete usage examples - README.md updated with getLogLevel() and loggerKey option - Configuration instructions and level mapping tables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 3 + INTEGRATIONS.md | 215 +++++++++++++++++++ README.md | 11 + package.json | 27 +++ src/__tests__/getLogLevel.test.ts | 219 ++++++++++++++++++++ src/__tests__/integrations/pino.test.ts | 228 +++++++++++++++++++++ src/__tests__/integrations/winston.test.ts | 196 ++++++++++++++++++ src/integrations/index.ts | 13 ++ src/integrations/pino.ts | 134 ++++++++++++ src/integrations/winston.ts | 156 ++++++++++++++ src/reforge.ts | 15 +- src/resolver.ts | 38 +++- src/types.ts | 1 + 13 files changed, 1253 insertions(+), 3 deletions(-) create mode 100644 INTEGRATIONS.md create mode 100644 src/__tests__/getLogLevel.test.ts create mode 100644 src/__tests__/integrations/pino.test.ts create mode 100644 src/__tests__/integrations/winston.test.ts create mode 100644 src/integrations/index.ts create mode 100644 src/integrations/pino.ts create mode 100644 src/integrations/winston.ts diff --git a/.gitignore b/.gitignore index 889d31f..d1aa4b2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules dist .envrc src/__tests__/temp-test.ts +.idea/ +.claude/ +.yarn/ diff --git a/INTEGRATIONS.md b/INTEGRATIONS.md new file mode 100644 index 0000000..081b23f --- /dev/null +++ b/INTEGRATIONS.md @@ -0,0 +1,215 @@ +# Logger Integrations + +Reforge provides integrations with popular Node.js logging frameworks to enable dynamic log level control. + +## Features + +- **Dynamic Log Levels**: Change log levels in real-time without restarting your application +- **Optional Dependencies**: Logger integrations are optional peer dependencies +- **Flexible Version Support**: Compatible with pino >=7.0.0 and winston >=3.0.0 + +## Installation + +Install Reforge along with your preferred logging framework: + +```bash +# With Pino +npm install @reforge-com/node pino + +# With Winston +npm install @reforge-com/node winston +``` + +## Pino Integration + +### Basic Usage + +```typescript +import { Reforge } from '@reforge-com/node'; +import { createPinoLogger } from '@reforge-com/node/integrations/pino'; + +const reforge = new Reforge({ + sdkKey: process.env.REFORGE_SDK_KEY, + loggerKey: 'my.log.config', // Config key for LOG_LEVEL_V2 (defaults to 'log-levels.default') +}); + +await reforge.init(); + +// Create a logger that dynamically gets its level from Reforge +const logger = await createPinoLogger(reforge, 'my.app.component', { + // Optional: any pino options + transport: { target: 'pino-pretty' } +}); + +if (logger) { + logger.info('Application started'); + logger.debug('Debug information'); // Only logs if level permits +} +``` + +### Using with Existing Pino Logger + +If you already have a Pino logger, you can use the `createPinoHook` to add Reforge log level information: + +```typescript +import pino from 'pino'; +import { createPinoHook } from '@reforge-com/node/integrations/pino'; + +const logger = pino({ + mixin: createPinoHook(reforge, 'my.app.component') +}); + +// Log entries will include reforgeLogLevel field +logger.info('test'); // { reforgeLogLevel: 'info', msg: 'test', ... } +``` + +## Winston Integration + +### Basic Usage + +```typescript +import { Reforge } from '@reforge-com/node'; +import { createWinstonLogger } from '@reforge-com/node/integrations/winston'; + +const reforge = new Reforge({ + sdkKey: process.env.REFORGE_SDK_KEY, + loggerKey: 'my.log.config', +}); + +await reforge.init(); + +// Create a logger that dynamically gets its level from Reforge +const logger = await createWinstonLogger(reforge, 'my.app.component', { + // Optional: any winston options + transports: [new winston.transports.Console()] +}); + +if (logger) { + logger.info('Application started'); + logger.debug('Debug information'); // Only logs if level permits +} +``` + +### Using with Existing Winston Logger + +```typescript +import winston from 'winston'; +import { createWinstonFormat } from '@reforge-com/node/integrations/winston'; + +const logger = winston.createLogger({ + format: winston.format.combine( + await createWinstonFormat(reforge, 'my.app.component'), + winston.format.json() + ), + transports: [new winston.transports.Console()] +}); + +// Log entries will include reforgeLogLevel field +logger.info('test'); // { reforgeLogLevel: 'info', message: 'test', ... } +``` + +## Configuring Log Levels in Reforge + +### Create a LOG_LEVEL_V2 Config + +1. In the Reforge UI, create a new config with type `LOG_LEVEL_V2` +2. Set your logger key (e.g., `my.log.config`) +3. Add rules based on the `reforge-sdk-logging.logger-path` context property + +### Example Rules + +```typescript +// Rule 1: DEBUG for specific component +{ + criteria: [ + { + propertyName: "reforge-sdk-logging.logger-path", + operator: "PROP_IS_ONE_OF", + valueToMatch: { stringList: { values: ["my.app.auth"] } } + } + ], + value: { logLevel: "DEBUG" } +} + +// Rule 2: INFO as default +{ + criteria: [], + value: { logLevel: "INFO" } +} +``` + +### Using getLogLevel() Directly + +You can also use `getLogLevel()` directly with any logging framework: + +```typescript +import { Reforge, LogLevel } from '@reforge-com/node'; + +const reforge = new Reforge({ + sdkKey: process.env.REFORGE_SDK_KEY, + loggerKey: 'my.log.config', +}); + +await reforge.init(); + +// Get the log level for a specific logger +const level = reforge.getLogLevel('my.app.component'); + +// Map to your logger's level system +if (level === LogLevel.Debug) { + myLogger.level = 'debug'; +} else if (level === LogLevel.Info) { + myLogger.level = 'info'; +} +// ... etc +``` + +## Log Level Mapping + +### Reforge → Pino +- `TRACE` → `trace` +- `DEBUG` → `debug` +- `INFO` → `info` +- `WARN` → `warn` +- `ERROR` → `error` +- `FATAL` → `fatal` + +### Reforge → Winston +- `TRACE` → `debug` (Winston doesn't have trace) +- `DEBUG` → `debug` +- `INFO` → `info` +- `WARN` → `warn` +- `ERROR` → `error` +- `FATAL` → `error` (Winston doesn't have fatal) + +## Important Notes + +1. **No Hierarchy Traversal**: Unlike the previous LOG_LEVEL implementation, LOG_LEVEL_V2 does NOT traverse logger name hierarchies. Each logger name is evaluated independently. + +2. **Default Value**: + - The default `loggerKey` is `"log-levels.default"` + - If no config is found with that key, `getLogLevel()` returns `DEBUG` + +3. **Dynamic Updates**: Log levels update automatically when your Reforge config changes (via SSE or polling). + +4. **Context Evaluation**: The integrations pass the logger name in the context as: + ```typescript + { + "reforge-sdk-logging": { + "lang": "javascript", + "logger-path": loggerName + } + } + ``` + +## Graceful Degradation + +If pino or winston is not installed, the integration functions will return `undefined` and log a warning to the console. Your application will continue to work, but dynamic log levels won't be available. + +```typescript +const logger = await createPinoLogger(reforge, 'my.app'); +if (!logger) { + console.warn('Pino not available, falling back to console'); + // Use console or another fallback +} +``` diff --git a/README.md b/README.md index 468c9dc..f09e0c7 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ After the init completes you can use - `reforge.get('some.config.name')` returns a raw value - `reforge.isFeatureEnabled('some.feature.name')` returns true or false - `reforge.shouldLog({loggerName, desiredLevel, defaultLevel, contexts})` returns true or false +- `reforge.getLogLevel(loggerName)` returns the configured log level for a logger name Reforge supports [context](https://docs.prefab.cloud/docs/explanations/concepts/context) for intelligent rule-based evaluation of `get` and `isFeatureEnabled` based on the current @@ -91,6 +92,15 @@ Note that you can also provide Context as an object instead of a Map, e.g.: } ``` +## Logger Integrations + +Reforge provides optional integrations with popular Node.js logging frameworks (Pino, Winston) for dynamic log level control. + +See [INTEGRATIONS.md](INTEGRATIONS.md) for detailed documentation on: +- Setting up Pino or Winston with dynamic log levels +- Using `getLogLevel()` with any logging framework +- Configuration and examples + #### Option Definitions Besides `apiKey`, you can initialize `new Reforge(...)` with the following options @@ -101,6 +111,7 @@ Besides `apiKey`, you can initialize `new Reforge(...)` with the following optio | collectLoggerCounts | Send counts of logger usage back to Reforge to power log-levels configuration screen | true | | contextUploadMode | Upload either context "shapes" (the names and data types your app uses in reforge contexts) or periodically send full example contexts | "periodicExample" | | defaultLevel | Level to be used as the min-verbosity for a `loggerPath` if no value is configured in Reforge | "warn" | +| loggerKey | Config key for LOG_LEVEL_V2 to use with `getLogLevel()` method | "log-levels.default" | | enableSSE | Whether or not we should listen for live changes from Reforge | true | | enablePolling | Whether or not we should poll for changes from Reforge | false | diff --git a/package.json b/package.json index 60ac634..ade489e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,21 @@ ".": { "import": "./dist/reforge.mjs", "require": "./dist/reforge.cjs" + }, + "./integrations/pino": { + "import": "./dist/integrations/pino.mjs", + "require": "./dist/integrations/pino.cjs", + "types": "./dist/integrations/pino.d.ts" + }, + "./integrations/winston": { + "import": "./dist/integrations/winston.mjs", + "require": "./dist/integrations/winston.cjs", + "types": "./dist/integrations/winston.d.ts" + }, + "./integrations": { + "import": "./dist/integrations/index.mjs", + "require": "./dist/integrations/index.cjs", + "types": "./dist/integrations/index.d.ts" } }, "files": [ @@ -59,5 +74,17 @@ "eventsource": "^4.0.0", "murmurhash": "^2.0.1", "node-forge": "^1.3.1" + }, + "peerDependencies": { + "pino": ">=7.0.0", + "winston": ">=3.0.0" + }, + "peerDependenciesMeta": { + "pino": { + "optional": true + }, + "winston": { + "optional": true + } } } diff --git a/src/__tests__/getLogLevel.test.ts b/src/__tests__/getLogLevel.test.ts new file mode 100644 index 0000000..586ae2e --- /dev/null +++ b/src/__tests__/getLogLevel.test.ts @@ -0,0 +1,219 @@ +import { Resolver } from "../resolver"; +import { Reforge } from "../reforge"; +import { ConfigType, ConfigValueType, LogLevel } from "../types"; +import type { Config } from "../types"; +import { + projectEnvIdUnderTest, + irrelevantNumberAsString, + irrelevantNumber, +} from "./testHelpers"; + +const getResolver = (configs: Config[], loggerKey?: string): Resolver => { + return new Resolver( + configs, + projectEnvIdUnderTest, + "some-namespace", + "error", + () => undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + loggerKey + ); +}; + +const createLogLevelV2Config = ( + key: string, + loggerPath: string, + logLevel: LogLevel +): Config => { + return { + id: irrelevantNumberAsString, + projectId: irrelevantNumber, + key, + changedBy: undefined, + rows: [ + { + properties: {}, + projectEnvId: projectEnvIdUnderTest, + values: [ + { + criteria: [ + { + propertyName: "reforge-sdk-logging.logger-path", + operator: "PROP_IS_ONE_OF" as any, + valueToMatch: { + stringList: { + values: [loggerPath], + }, + }, + }, + ], + value: { + logLevel, + }, + }, + { + criteria: [], + value: { + logLevel: LogLevel.Warn, + }, + }, + ], + }, + ], + allowableValues: [], + configType: ConfigType.LogLevelV2, + valueType: ConfigValueType.LogLevel, + sendToClientSdk: false, + }; +}; + +describe("getLogLevel", () => { + describe("Resolver.getLogLevel", () => { + it("returns DEBUG when loggerKey is undefined", () => { + const resolver = getResolver([], undefined); + + expect(resolver.getLogLevel("any.logger.name")).toBe(LogLevel.Debug); + }); + + it("returns DEBUG when config with loggerKey does not exist", () => { + const resolver = getResolver([], "my.logger.config"); + + expect(resolver.getLogLevel("any.logger.name")).toBe(LogLevel.Debug); + }); + + it("uses default loggerKey 'log-levels.default' when not specified", () => { + const config = createLogLevelV2Config( + "log-levels.default", + "my.app.component", + LogLevel.Info + ); + const resolver = getResolver([config], "log-levels.default"); + + expect(resolver.getLogLevel("my.app.component")).toBe(LogLevel.Info); + }); + + it("returns the configured log level when context matches", () => { + const config = createLogLevelV2Config( + "my.logger.config", + "my.app.component", + LogLevel.Trace + ); + const resolver = getResolver([config], "my.logger.config"); + + expect(resolver.getLogLevel("my.app.component")).toBe(LogLevel.Trace); + }); + + it("returns default level when context does not match", () => { + const config = createLogLevelV2Config( + "my.logger.config", + "my.app.component", + LogLevel.Trace + ); + const resolver = getResolver([config], "my.logger.config"); + + // This logger name doesn't match the criteria, so it should get the default from the config + expect(resolver.getLogLevel("different.logger.name")).toBe(LogLevel.Warn); + }); + + it("evaluates with correct context structure", () => { + const config = createLogLevelV2Config( + "my.logger.config", + "com.example.service", + LogLevel.Info + ); + const resolver = getResolver([config], "my.logger.config"); + + expect(resolver.getLogLevel("com.example.service")).toBe(LogLevel.Info); + }); + + it("supports multiple log level configurations", () => { + const config1 = createLogLevelV2Config( + "my.logger.config", + "service.auth", + LogLevel.Debug + ); + const config2 = createLogLevelV2Config( + "my.logger.config", + "service.database", + LogLevel.Error + ); + + const resolver = getResolver([config1, config2], "my.logger.config"); + + // Only config1 will be used since they have the same key + // The last one wins in the config map + expect(resolver.getLogLevel("service.database")).toBe(LogLevel.Error); + }); + + it("does not traverse logger hierarchy like old implementation", () => { + // Create a config that matches "my.app" + const config = createLogLevelV2Config( + "my.logger.config", + "my.app", + LogLevel.Trace + ); + const resolver = getResolver([config], "my.logger.config"); + + // Old implementation would traverse up to find "my.app" for "my.app.component" + // New implementation does NOT do this - it only evaluates the exact logger name + expect(resolver.getLogLevel("my.app.component")).toBe(LogLevel.Warn); + + // But exact match still works + expect(resolver.getLogLevel("my.app")).toBe(LogLevel.Trace); + }); + }); + + describe("Reforge.getLogLevel", () => { + it("throws when not initialized", () => { + const reforge = new Reforge({ + sdkKey: "test-key", + loggerKey: "my.logger.config", + }); + + expect(() => reforge.getLogLevel("any.logger.name")).toThrow( + "reforge.resolver is undefined. Did you call init()?" + ); + }); + + it("uses default loggerKey 'log-levels.default' when not specified", () => { + const reforge = new Reforge({ + sdkKey: "test-key", + enableSSE: false, + enablePolling: false, + }); + + const config = createLogLevelV2Config( + "log-levels.default", + "test.logger", + LogLevel.Warn + ); + + reforge.setConfig([config], projectEnvIdUnderTest, new Map()); + + expect(reforge.getLogLevel("test.logger")).toBe(LogLevel.Warn); + }); + + it("works after initialization", async () => { + const config = createLogLevelV2Config( + "my.logger.config", + "test.logger", + LogLevel.Error + ); + + const reforge = new Reforge({ + sdkKey: "test-key", + loggerKey: "my.logger.config", + enableSSE: false, + enablePolling: false, + }); + + reforge.setConfig([config], projectEnvIdUnderTest, new Map()); + + expect(reforge.getLogLevel("test.logger")).toBe(LogLevel.Error); + }); + }); +}); diff --git a/src/__tests__/integrations/pino.test.ts b/src/__tests__/integrations/pino.test.ts new file mode 100644 index 0000000..06d1aa4 --- /dev/null +++ b/src/__tests__/integrations/pino.test.ts @@ -0,0 +1,228 @@ +import { Reforge } from "../../reforge"; +import { LogLevel } from "../../types"; +import { createPinoLogger, createPinoHook } from "../../integrations/pino"; +import { projectEnvIdUnderTest } from "../testHelpers"; + +// Check if pino is installed +let pinoInstalled = false; +try { + require.resolve("pino"); + pinoInstalled = true; +} catch { + pinoInstalled = false; +} + +// Create a mock logger function +const mockPinoLogger: any = { + level: "info", + trace: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + fatal: jest.fn(), +}; + +const mockPinoFn = jest.fn((options: any) => { + mockPinoLogger.level = options.level; + return mockPinoLogger; +}); + +// Mock the pino module using doMock with moduleFactory +if (pinoInstalled) { + jest.doMock("pino", () => ({ + default: mockPinoFn, + }), { virtual: true }); +} + +describe("Pino Integration", () => { + beforeAll(() => { + if (!pinoInstalled) { + console.log("Skipping Pino integration tests - pino not installed. Install with: npm install pino"); + } + }); + let reforge: Reforge; + + beforeEach(() => { + reforge = new Reforge({ + sdkKey: "test-key", + loggerKey: "my.logger.config", + enableSSE: false, + enablePolling: false, + }); + + reforge.setConfig( + [ + { + id: "1", + projectId: 1, + key: "my.logger.config", + changedBy: undefined, + rows: [ + { + properties: {}, + projectEnvId: projectEnvIdUnderTest, + values: [ + { + criteria: [], + value: { + logLevel: LogLevel.Debug, + }, + }, + ], + }, + ], + allowableValues: [], + configType: "LOG_LEVEL_V2" as any, + valueType: "LOG_LEVEL" as any, + sendToClientSdk: false, + }, + ], + projectEnvIdUnderTest, + new Map() + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("createPinoLogger", () => { + (pinoInstalled ? it : it.skip)("creates a Pino logger with the correct initial level", async () => { + const logger = await createPinoLogger(reforge, "my.app.component"); + + expect(logger).toBeDefined(); + expect(logger.level).toBe("debug"); + }); + + (pinoInstalled ? it : it.skip)("includes the logger name in options", async () => { + await createPinoLogger(reforge, "my.app.component"); + + expect(mockPinoFn).toHaveBeenCalledWith( + expect.objectContaining({ + name: "my.app.component", + level: "debug", + }) + ); + }); + + (pinoInstalled ? it : it.skip)("merges custom Pino options", async () => { + const customOptions = { + transport: { target: "pino-pretty" }, + base: { pid: 123 }, + }; + + await createPinoLogger(reforge, "my.app.component", customOptions); + + expect(mockPinoFn).toHaveBeenCalledWith( + expect.objectContaining({ + name: "my.app.component", + level: "debug", + transport: { target: "pino-pretty" }, + base: { pid: 123 }, + }) + ); + }); + + (pinoInstalled ? it : it.skip)("maps Reforge log levels to Pino levels correctly", async () => { + + const testCases = [ + { reforge: LogLevel.Trace, pino: "trace" }, + { reforge: LogLevel.Debug, pino: "debug" }, + { reforge: LogLevel.Info, pino: "info" }, + { reforge: LogLevel.Warn, pino: "warn" }, + { reforge: LogLevel.Error, pino: "error" }, + { reforge: LogLevel.Fatal, pino: "fatal" }, + ]; + + for (const testCase of testCases) { + // Update config for each level + reforge.setConfig( + [ + { + id: "1", + projectId: 1, + key: "my.logger.config", + changedBy: undefined, + rows: [ + { + properties: {}, + projectEnvId: projectEnvIdUnderTest, + values: [ + { + criteria: [], + value: { + logLevel: testCase.reforge, + }, + }, + ], + }, + ], + allowableValues: [], + configType: "LOG_LEVEL_V2" as any, + valueType: "LOG_LEVEL" as any, + sendToClientSdk: false, + }, + ], + projectEnvIdUnderTest, + new Map() + ); + + const logger = await createPinoLogger(reforge, "my.app.component"); + expect(logger.level).toBe(testCase.pino); + } + }); + }); + + describe("createPinoHook", () => { + (pinoInstalled ? it : it.skip)("returns a mixin function that includes the Reforge log level", () => { + const mixin = createPinoHook(reforge, "my.app.component"); + const result = mixin(); + + expect(result).toHaveProperty("reforgeLogLevel"); + expect(result["reforgeLogLevel"]).toBe("debug"); + }); + + (pinoInstalled ? it : it.skip)("updates when Reforge log level changes", () => { + const mixin = createPinoHook(reforge, "my.app.component"); + + let result = mixin(); + expect(result["reforgeLogLevel"]).toBe("debug"); + + // Update the config + reforge.setConfig( + [ + { + id: "1", + projectId: 1, + key: "my.logger.config", + changedBy: undefined, + rows: [ + { + properties: {}, + projectEnvId: projectEnvIdUnderTest, + values: [ + { + criteria: [], + value: { + logLevel: LogLevel.Error, + }, + }, + ], + }, + ], + allowableValues: [], + configType: "LOG_LEVEL_V2" as any, + valueType: "LOG_LEVEL" as any, + sendToClientSdk: false, + }, + ], + projectEnvIdUnderTest, + new Map() + ); + + result = mixin(); + expect(result["reforgeLogLevel"]).toBe("error"); + }); + }); +}); diff --git a/src/__tests__/integrations/winston.test.ts b/src/__tests__/integrations/winston.test.ts new file mode 100644 index 0000000..979c6ce --- /dev/null +++ b/src/__tests__/integrations/winston.test.ts @@ -0,0 +1,196 @@ +import { Reforge } from "../../reforge"; +import { LogLevel } from "../../types"; +import { + createWinstonLogger, + createWinstonFormat, +} from "../../integrations/winston"; +import { projectEnvIdUnderTest } from "../testHelpers"; + +// Check if winston is installed +let winstonInstalled = false; +try { + require.resolve("winston"); + winstonInstalled = true; +} catch { + winstonInstalled = false; +} + +// Create mock Winston objects +const mockWinstonLogger: any = { + level: "info", + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + http: jest.fn(), + verbose: jest.fn(), + debug: jest.fn(), + silly: jest.fn(), +}; + +const mockCreateLogger = jest.fn((options: any) => { + mockWinstonLogger.level = options.level; + return mockWinstonLogger; +}); + +// Mock the winston module using doMock with moduleFactory +if (winstonInstalled) { + jest.doMock("winston", () => ({ + createLogger: mockCreateLogger, + format: { + combine: jest.fn((...formats: any[]) => formats), + timestamp: jest.fn(), + errors: jest.fn(() => "errors"), + splat: jest.fn(), + json: jest.fn(), + }, + transports: { + Console: jest.fn(), + }, + }), { virtual: true }); +} + +describe("Winston Integration", () => { + beforeAll(() => { + if (!winstonInstalled) { + console.log("Skipping Winston integration tests - winston not installed. Install with: npm install winston"); + } + }); + let reforge: Reforge; + + beforeEach(() => { + reforge = new Reforge({ + sdkKey: "test-key", + loggerKey: "my.logger.config", + enableSSE: false, + enablePolling: false, + }); + + reforge.setConfig( + [ + { + id: "1", + projectId: 1, + key: "my.logger.config", + changedBy: undefined, + rows: [ + { + properties: {}, + projectEnvId: projectEnvIdUnderTest, + values: [ + { + criteria: [], + value: { + logLevel: LogLevel.Debug, + }, + }, + ], + }, + ], + allowableValues: [], + configType: "LOG_LEVEL_V2" as any, + valueType: "LOG_LEVEL" as any, + sendToClientSdk: false, + }, + ], + projectEnvIdUnderTest, + new Map() + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("createWinstonLogger", () => { + (winstonInstalled ? it : it.skip)("creates a Winston logger with the correct initial level", async () => { + const logger = await createWinstonLogger(reforge, "my.app.component"); + + expect(logger).toBeDefined(); + expect(logger.level).toBe("debug"); + }); + + (winstonInstalled ? it : it.skip)("includes the logger name in defaultMeta", async () => { + await createWinstonLogger(reforge, "my.app.component"); + + expect(mockCreateLogger).toHaveBeenCalledWith( + expect.objectContaining({ + level: "debug", + defaultMeta: { loggerName: "my.app.component" }, + }) + ); + }); + + (winstonInstalled ? it : it.skip)("merges custom Winston options", async () => { + const customOptions = { + transports: [], + exitOnError: false, + }; + + await createWinstonLogger(reforge, "my.app.component", customOptions); + + expect(mockCreateLogger).toHaveBeenCalledWith( + expect.objectContaining({ + level: "debug", + exitOnError: false, + }) + ); + }); + + (winstonInstalled ? it : it.skip)("maps Reforge log levels to Winston levels correctly", async () => { + + const testCases = [ + { reforge: LogLevel.Trace, winston: "debug" }, // Winston doesn't have trace + { reforge: LogLevel.Debug, winston: "debug" }, + { reforge: LogLevel.Info, winston: "info" }, + { reforge: LogLevel.Warn, winston: "warn" }, + { reforge: LogLevel.Error, winston: "error" }, + { reforge: LogLevel.Fatal, winston: "error" }, // Winston doesn't have fatal + ]; + + for (const testCase of testCases) { + // Update config for each level + reforge.setConfig( + [ + { + id: "1", + projectId: 1, + key: "my.logger.config", + changedBy: undefined, + rows: [ + { + properties: {}, + projectEnvId: projectEnvIdUnderTest, + values: [ + { + criteria: [], + value: { + logLevel: testCase.reforge, + }, + }, + ], + }, + ], + allowableValues: [], + configType: "LOG_LEVEL_V2" as any, + valueType: "LOG_LEVEL" as any, + sendToClientSdk: false, + }, + ], + projectEnvIdUnderTest, + new Map() + ); + + const logger = await createWinstonLogger(reforge, "my.app.component"); + expect(logger.level).toBe(testCase.winston); + } + }); + }); + + describe("createWinstonFormat", () => { + (winstonInstalled ? it : it.skip)("returns a Winston format function", async () => { + const format = await createWinstonFormat(reforge, "my.app.component"); + + expect(format).toBeDefined(); + }); + }); +}); diff --git a/src/integrations/index.ts b/src/integrations/index.ts new file mode 100644 index 0000000..a137587 --- /dev/null +++ b/src/integrations/index.ts @@ -0,0 +1,13 @@ +/** + * Logger integrations for popular Node.js logging frameworks. + * + * These integrations allow you to use Reforge's dynamic log levels with + * your existing logging infrastructure. + * + * All integrations are optional and will gracefully handle missing dependencies. + * + * @packageDocumentation + */ + +export { createPinoLogger, createPinoHook } from "./pino"; +export { createWinstonLogger, createWinstonFormat } from "./winston"; diff --git a/src/integrations/pino.ts b/src/integrations/pino.ts new file mode 100644 index 0000000..cdffa1b --- /dev/null +++ b/src/integrations/pino.ts @@ -0,0 +1,134 @@ +import type { Reforge } from "../reforge"; +import { LogLevel } from "../types"; + +/** + * Creates a Pino logger instance with dynamic log levels from Reforge. + * + * @param reforge - The Reforge instance + * @param loggerName - The name of the logger (used to fetch log level from Reforge) + * @param pinoOptions - Optional Pino options to pass to the logger + * @returns A Pino logger instance or undefined if Pino is not installed + * + * @example + * ```typescript + * import { Reforge } from '@reforge-com/node'; + * import { createPinoLogger } from '@reforge-com/node/integrations/pino'; + * + * const reforge = new Reforge({ + * sdkKey: process.env.REFORGE_SDK_KEY, + * loggerKey: 'my.log.config' + * }); + * + * await reforge.init(); + * + * const logger = createPinoLogger(reforge, 'my.app.component', { + * // optional Pino options + * transport: { target: 'pino-pretty' } + * }); + * + * if (logger) { + * logger.info('Hello world'); + * } + * ``` + */ +export async function createPinoLogger( + reforge: Reforge, + loggerName: string, + pinoOptions: any = {} +): Promise { + try { + // Dynamically import pino only if it's available + // @ts-ignore - pino is an optional peer dependency + const pino = await import("pino"); + + // Map Reforge LogLevel to Pino level names + const levelMap: Record = { + [LogLevel.Trace]: "trace", + [LogLevel.Debug]: "debug", + [LogLevel.Info]: "info", + [LogLevel.Warn]: "warn", + [LogLevel.Error]: "error", + [LogLevel.Fatal]: "fatal", + }; + + // Get the current log level from Reforge + const reforgeLevel = reforge.getLogLevel(loggerName); + const pinoLevel = levelMap[reforgeLevel] ?? "info"; + + // Create the logger with the determined level + const logger = pino.default({ + ...pinoOptions, + level: pinoLevel, + name: loggerName, + }); + + // Create a wrapper that updates the level dynamically + const updateLevel = (): void => { + const newReforgeLevel = reforge.getLogLevel(loggerName); + const newPinoLevel = levelMap[newReforgeLevel] ?? "info"; + logger.level = newPinoLevel; + }; + + // Update level on each call if needed (for dynamic updates) + const wrappedLogger = new Proxy(logger, { + get(target, prop) { + // Update level before each log call + if ( + typeof prop === "string" && + ["trace", "debug", "info", "warn", "error", "fatal"].includes(prop) + ) { + updateLevel(); + } + return target[prop as keyof typeof target]; + }, + }); + + return wrappedLogger; + } catch (error) { + console.warn( + "[reforge] Pino is not installed. Install it with: npm install pino" + ); + return undefined; + } +} + +/** + * Creates a custom Pino hook that updates log levels from Reforge. + * + * This can be used if you already have a Pino logger instance and want to + * integrate it with Reforge's dynamic log levels. + * + * @param reforge - The Reforge instance + * @param loggerName - The name of the logger (used to fetch log level from Reforge) + * @returns A Pino mixin function + * + * @example + * ```typescript + * import pino from 'pino'; + * import { createPinoHook } from '@reforge-com/node/integrations/pino'; + * + * const logger = pino({ + * mixin: createPinoHook(reforge, 'my.app.component') + * }); + * ``` + */ +export function createPinoHook( + reforge: Reforge, + loggerName: string +): () => Record { + const levelMap: Record = { + [LogLevel.Trace]: "trace", + [LogLevel.Debug]: "debug", + [LogLevel.Info]: "info", + [LogLevel.Warn]: "warn", + [LogLevel.Error]: "error", + [LogLevel.Fatal]: "fatal", + }; + + return () => { + const reforgeLevel = reforge.getLogLevel(loggerName); + return { + reforgeLogLevel: levelMap[reforgeLevel], + }; + }; +} diff --git a/src/integrations/winston.ts b/src/integrations/winston.ts new file mode 100644 index 0000000..c960e3c --- /dev/null +++ b/src/integrations/winston.ts @@ -0,0 +1,156 @@ +import type { Reforge } from "../reforge"; +import { LogLevel } from "../types"; + +/** + * Creates a Winston logger instance with dynamic log levels from Reforge. + * + * @param reforge - The Reforge instance + * @param loggerName - The name of the logger (used to fetch log level from Reforge) + * @param winstonOptions - Optional Winston options to pass to the logger + * @returns A Winston logger instance or undefined if Winston is not installed + * + * @example + * ```typescript + * import { Reforge } from '@reforge-com/node'; + * import { createWinstonLogger } from '@reforge-com/node/integrations/winston'; + * + * const reforge = new Reforge({ + * sdkKey: process.env.REFORGE_SDK_KEY, + * loggerKey: 'my.log.config' + * }); + * + * await reforge.init(); + * + * const logger = createWinstonLogger(reforge, 'my.app.component', { + * // optional Winston options + * transports: [new winston.transports.Console()] + * }); + * + * if (logger) { + * logger.info('Hello world'); + * } + * ``` + */ +export async function createWinstonLogger( + reforge: Reforge, + loggerName: string, + winstonOptions: any = {} +): Promise { + try { + // Dynamically import winston only if it's available + // @ts-ignore - winston is an optional peer dependency + const winston = await import("winston"); + + // Map Reforge LogLevel to Winston level names + const levelMap: Record = { + [LogLevel.Trace]: "debug", // Winston doesn't have trace, map to debug + [LogLevel.Debug]: "debug", + [LogLevel.Info]: "info", + [LogLevel.Warn]: "warn", + [LogLevel.Error]: "error", + [LogLevel.Fatal]: "error", // Winston doesn't have fatal, map to error + }; + + // Get the current log level from Reforge + const reforgeLevel = reforge.getLogLevel(loggerName); + const winstonLevel = levelMap[reforgeLevel] ?? "info"; + + // Create the logger with the determined level + const logger = winston.createLogger({ + level: winstonLevel, + defaultMeta: { loggerName }, + format: winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.splat(), + winston.format.json() + ), + transports: [new winston.transports.Console()], + ...winstonOptions, + }); + + // Create a wrapper that updates the level dynamically + const updateLevel = (): void => { + const newReforgeLevel = reforge.getLogLevel(loggerName); + const newWinstonLevel = levelMap[newReforgeLevel] ?? "info"; + logger.level = newWinstonLevel; + }; + + // Update level on each call if needed (for dynamic updates) + const wrappedLogger = new Proxy(logger, { + get(target, prop) { + // Update level before each log call + if ( + typeof prop === "string" && + ["error", "warn", "info", "http", "verbose", "debug", "silly"].includes( + prop + ) + ) { + updateLevel(); + } + return target[prop as keyof typeof target]; + }, + }); + + return wrappedLogger; + } catch (error) { + console.warn( + "[reforge] Winston is not installed. Install it with: npm install winston" + ); + return undefined; + } +} + +/** + * Creates a custom Winston format that includes the Reforge log level. + * + * This can be used if you already have a Winston logger instance and want to + * integrate it with Reforge's dynamic log levels. + * + * @param reforge - The Reforge instance + * @param loggerName - The name of the logger (used to fetch log level from Reforge) + * @returns A Winston format function + * + * @example + * ```typescript + * import winston from 'winston'; + * import { createWinstonFormat } from '@reforge-com/node/integrations/winston'; + * + * const logger = winston.createLogger({ + * format: winston.format.combine( + * createWinstonFormat(reforge, 'my.app.component'), + * winston.format.json() + * ), + * transports: [new winston.transports.Console()] + * }); + * ``` + */ +export async function createWinstonFormat( + reforge: Reforge, + loggerName: string +): Promise { + try { + // @ts-ignore - winston is an optional peer dependency + const winston = await import("winston"); + + const levelMap: Record = { + [LogLevel.Trace]: "debug", + [LogLevel.Debug]: "debug", + [LogLevel.Info]: "info", + [LogLevel.Warn]: "warn", + [LogLevel.Error]: "error", + [LogLevel.Fatal]: "error", + }; + + return winston.format((info: any) => { + const reforgeLevel = reforge.getLogLevel(loggerName); + info.reforgeLogLevel = levelMap[reforgeLevel]; + return info; + })(); + } catch (error) { + console.warn( + "[reforge] Winston is not installed. Install it with: npm install winston" + ); + return undefined; + } +} diff --git a/src/reforge.ts b/src/reforge.ts index 3ffa368..db71513 100644 --- a/src/reforge.ts +++ b/src/reforge.ts @@ -39,6 +39,7 @@ import { const DEFAULT_POLL_INTERVAL = 60 * 1000; export const REFORGE_DEFAULT_LOG_LEVEL = LogLevel.Warn; +export const DEFAULT_LOGGER_KEY = "log-levels.default"; export const MULTIPLE_INIT_WARNING = "[reforge] init() called multiple times. This is generally not recommended as it can lead to multiple concurrent SSE connections and/or redundant polling. A Reforge instance is typically meant to be long-lived and exist outside of your request/response life-cycle. If you're using `init()` to change context, you're better off using `inContext` or setting per-request context to pass to your `get`/etc. calls."; @@ -95,6 +96,7 @@ export interface ReforgeInterface { defaultLevel?: LogLevel; contexts?: Contexts | ContextObj; }) => boolean; + getLogLevel: (loggerName: string) => LogLevel; telemetry?: Telemetry; updateIfStalerThan: (durationInMs: number) => Promise | undefined; withContext: (contexts: Contexts | ContextObj) => ResolverAPI; @@ -120,6 +122,7 @@ interface ConstructorProps { pollInterval?: number; fetch?: Fetch; defaultLogLevel?: LogLevel; + loggerKey?: string; collectLoggerCounts?: boolean; contextUploadMode?: ContextUploadMode; collectEvaluationSummaries?: boolean; @@ -138,6 +141,7 @@ class Reforge implements ReforgeInterface { private resolver: Resolver | undefined; private readonly apiClient: ApiClient; private readonly defaultLogLevel: LogLevel; + private readonly loggerKey?: string; private readonly instanceHash: string; private readonly onUpdate: (configs: Array) => void; private initCount: number = 0; @@ -163,12 +167,14 @@ class Reforge implements ReforgeInterface { pollInterval, fetch = globalThis.fetch, defaultLogLevel = REFORGE_DEFAULT_LOG_LEVEL, + loggerKey = DEFAULT_LOGGER_KEY, collectLoggerCounts = true, contextUploadMode = "periodicExample", collectEvaluationSummaries = true, onUpdate, }: ConstructorProps) { this.sdkKey = sdkKey; + this.loggerKey = loggerKey; if ( process.env["REFORGE_API_URL_OVERRIDE"] !== undefined && @@ -243,7 +249,8 @@ class Reforge implements ReforgeInterface { undefined, () => {}, defaultContext, - this.globalContext + this.globalContext, + this.loggerKey ); this.configChangeNotifier.init(tempResolver); @@ -474,6 +481,12 @@ class Reforge implements ReforgeInterface { return this.resolver.isFeatureEnabled(key, contexts); } + getLogLevel(loggerName: string): LogLevel { + requireResolver(this.resolver); + + return this.resolver.getLogLevel(loggerName); + } + raw(key: string): MinimumConfig | undefined { requireResolver(this.resolver); diff --git a/src/resolver.ts b/src/resolver.ts index c0e8ebe..3239608 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -9,6 +9,7 @@ import { type ConfigValue, type LogLevel, ConfigType, + LogLevel as LogLevelEnum, } from "./types"; import type { Telemetry, TypedNodeServerConfigurationRaw } from "./reforge"; import { REFORGE_DEFAULT_LOG_LEVEL } from "./reforge"; @@ -29,6 +30,7 @@ export interface ResolverAPI { contexts?: Contexts; readonly telemetry: Telemetry | undefined; readonly defaultContext?: Contexts; + readonly loggerKey?: string; updateIfStalerThan: | ((durationInMs: number) => Promise | undefined) | undefined; @@ -60,6 +62,7 @@ export interface ResolverAPI { defaultLevel?: LogLevel; contexts?: Contexts | ContextObj; }) => boolean; + getLogLevel: (loggerName: string) => LogLevel; setOnUpdate: ( onUpdate: (configs: Array) => void ) => void; @@ -112,6 +115,7 @@ class Resolver implements ResolverAPI { private readonly globalContext?: Contexts; public id: number; public readonly defaultContext?: Contexts; + public readonly loggerKey?: string; public updateIfStalerThan: ( durationInMs: number ) => Promise | undefined; @@ -126,7 +130,8 @@ class Resolver implements ResolverAPI { contexts?: Contexts | ContextObj, onInitialUpdate?: (configs: Array) => void, defaultContext?: Contexts, - globalContext?: Contexts + globalContext?: Contexts, + loggerKey?: string ) { id += 1; this.id = id; @@ -136,6 +141,7 @@ class Resolver implements ResolverAPI { this.onUpdate = onInitialUpdate ?? (() => {}); this.defaultContext = defaultContext ?? new Map(); this.globalContext = globalContext ?? new Map(); + this.loggerKey = loggerKey; this.contexts = mergeDefaultContexts( this.globalContext, mergeDefaultContexts(contexts ?? new Map(), defaultContext ?? new Map()) @@ -158,7 +164,8 @@ class Resolver implements ResolverAPI { contexts, this.onUpdate, this.defaultContext, - this.globalContext + this.globalContext, + this.loggerKey ); } @@ -335,6 +342,33 @@ class Resolver implements ResolverAPI { }); } + getLogLevel(loggerName: string): LogLevel { + const key = this.loggerKey; + + if (key === undefined) { + return LogLevelEnum.Debug; + } + + const contexts: Contexts = new Map([ + [ + "reforge-sdk-logging", + new Map([ + ["lang", "javascript"], + ["logger-path", loggerName], + ]), + ], + ]); + + const result = this.get(key, contexts, LogLevelEnum.Debug, "ignore"); + + // Validate that the result is actually a LogLevel + if (typeof result === "string" && Object.values(LogLevelEnum).includes(result as LogLevel)) { + return result as LogLevel; + } + + return LogLevelEnum.Debug; + } + public setOnUpdate( onUpdate: (configs: Array) => void ): void { diff --git a/src/types.ts b/src/types.ts index 00201cc..a21d322 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,7 @@ export enum ConfigType { Config = "CONFIG", FeatureFlag = "FEATURE_FLAG", LogLevel = "LOG_LEVEL", + LogLevelV2 = "LOG_LEVEL_V2", Segment = "SEGMENT", LimitDefinition = "LIMIT_DEFINITION", Deleted = "DELETED", From adac5d7eb9945ba149624c76c6f4499100fe95b4 Mon Sep 17 00:00:00 2001 From: James Kebinger Date: Tue, 4 Nov 2025 12:31:24 -0500 Subject: [PATCH 2/6] Update lockfile --- yarn.lock | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/yarn.lock b/yarn.lock index 8516573..73c9c1c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -942,6 +942,14 @@ __metadata: ts-node: "npm:^10.9.1" typescript: "npm:^5.0.4" yaml: "npm:^2.2.2" + peerDependencies: + pino: ">=7.0.0" + winston: ">=3.0.0" + peerDependenciesMeta: + pino: + optional: true + winston: + optional: true languageName: unknown linkType: soft From 70b38e4ed46826140c4b63660ad15c8e72deded1 Mon Sep 17 00:00:00 2001 From: James Kebinger Date: Tue, 4 Nov 2025 12:41:23 -0500 Subject: [PATCH 3/6] fix: add @types/node and replace @ts-ignore with @ts-expect-error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add @types/node to devDependencies to satisfy ts-node peer dependency - Replace @ts-ignore with @ts-expect-error in pino and winston integrations - Fixes linter errors and peer dependency warnings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package.json | 1 + src/integrations/pino.ts | 2 +- src/integrations/winston.ts | 4 ++-- yarn.lock | 17 +++++++++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index ade489e..7024885 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "license": "ISC", "devDependencies": { "@types/jest": "^29.5.1", + "@types/node": "^24.10.0", "@types/node-forge": "^1", "@types/yaml": "^1.9.7", "@typescript-eslint/eslint-plugin": "^5.59.2", diff --git a/src/integrations/pino.ts b/src/integrations/pino.ts index cdffa1b..89a60dc 100644 --- a/src/integrations/pino.ts +++ b/src/integrations/pino.ts @@ -38,7 +38,7 @@ export async function createPinoLogger( ): Promise { try { // Dynamically import pino only if it's available - // @ts-ignore - pino is an optional peer dependency + // @ts-expect-error - pino is an optional peer dependency const pino = await import("pino"); // Map Reforge LogLevel to Pino level names diff --git a/src/integrations/winston.ts b/src/integrations/winston.ts index c960e3c..90a3696 100644 --- a/src/integrations/winston.ts +++ b/src/integrations/winston.ts @@ -38,7 +38,7 @@ export async function createWinstonLogger( ): Promise { try { // Dynamically import winston only if it's available - // @ts-ignore - winston is an optional peer dependency + // @ts-expect-error - winston is an optional peer dependency const winston = await import("winston"); // Map Reforge LogLevel to Winston level names @@ -130,7 +130,7 @@ export async function createWinstonFormat( loggerName: string ): Promise { try { - // @ts-ignore - winston is an optional peer dependency + // @ts-expect-error - winston is an optional peer dependency const winston = await import("winston"); const levelMap: Record = { diff --git a/yarn.lock b/yarn.lock index 73c9c1c..d6e9bf7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -922,6 +922,7 @@ __metadata: resolution: "@reforge-com/node@workspace:." dependencies: "@types/jest": "npm:^29.5.1" + "@types/node": "npm:^24.10.0" "@types/node-forge": "npm:^1" "@types/yaml": "npm:^1.9.7" "@typescript-eslint/eslint-plugin": "npm:^5.59.2" @@ -1130,6 +1131,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^24.10.0": + version: 24.10.0 + resolution: "@types/node@npm:24.10.0" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10c0/f82ed7194e16f5590ef7afdc20c6d09068c76d50278b485ede8f0c5749683536e3064ffa8def8db76915196afb3724b854aa5723c64d6571b890b14492943b46 + languageName: node + linkType: hard + "@types/semver@npm:^7.3.12": version: 7.7.0 resolution: "@types/semver@npm:7.7.0" @@ -5725,6 +5735,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a + languageName: node + linkType: hard + "unique-filename@npm:^4.0.0": version: 4.0.0 resolution: "unique-filename@npm:4.0.0" From 895c6fc7214b25f4a4abe96cd58fbdd913dc9af3 Mon Sep 17 00:00:00 2001 From: James Kebinger Date: Tue, 4 Nov 2025 12:49:41 -0500 Subject: [PATCH 4/6] chore: bump version to 0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release includes: - LOG_LEVEL_V2 with getLogLevel() and logger integrations - Pino and Winston logger integrations - Bug fixes and dependency updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 21 +++++++++++++++++++++ package.json | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e70b551..4aa15e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.1.0 - 2025-11-04 + +### Features + +- feat: add LOG_LEVEL_V2 with `getLogLevel()` method for dynamic log level control +- feat: add Pino logger integration (`createPinoLogger()`, `createPinoHook()`) +- feat: add Winston logger integration (`createWinstonLogger()`, `createWinstonFormat()`) +- feat: add `loggerKey` option to Reforge constructor (defaults to 'log-levels.default') +- feat: add ConfigType.LogLevelV2 enum value +- feat: support context-aware log level evaluation with `reforge-sdk-logging.logger-path` + +### Fixes + +- fix: add @types/node to devDependencies to satisfy ts-node peer dependency +- fix: replace @ts-ignore with @ts-expect-error for better type safety in integrations + +### Documentation + +- docs: add INTEGRATIONS.md with complete logger integration examples +- docs: update README.md with getLogLevel() usage and loggerKey option + ## 0.0.7 - 2025-10-31 - fix: configs with duration would cause telemetry not to send diff --git a/package.json b/package.json index 7024885..05ccb2d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "packageManager": "yarn@4.9.2", "name": "@reforge-com/node", - "version": "0.0.7", + "version": "0.1.0", "description": "Feature Flags, Live Config, and Dynamic Log Levels", "main": "dist/reforge.cjs", "types": "dist/reforge.d.ts", From ad155900e330c2b6ddfcd023c803c77561739fa4 Mon Sep 17 00:00:00 2001 From: James Kebinger Date: Tue, 4 Nov 2025 12:52:58 -0500 Subject: [PATCH 5/6] prettier --- src/__tests__/integrations/pino.test.ts | 190 ++++++++++--------- src/__tests__/integrations/winston.test.ts | 202 +++++++++++---------- src/integrations/winston.ts | 12 +- src/resolver.ts | 5 +- 4 files changed, 229 insertions(+), 180 deletions(-) diff --git a/src/__tests__/integrations/pino.test.ts b/src/__tests__/integrations/pino.test.ts index 06d1aa4..4a738d6 100644 --- a/src/__tests__/integrations/pino.test.ts +++ b/src/__tests__/integrations/pino.test.ts @@ -30,15 +30,21 @@ const mockPinoFn = jest.fn((options: any) => { // Mock the pino module using doMock with moduleFactory if (pinoInstalled) { - jest.doMock("pino", () => ({ - default: mockPinoFn, - }), { virtual: true }); + jest.doMock( + "pino", + () => ({ + default: mockPinoFn, + }), + { virtual: true } + ); } describe("Pino Integration", () => { beforeAll(() => { if (!pinoInstalled) { - console.log("Skipping Pino integration tests - pino not installed. Install with: npm install pino"); + console.log( + "Skipping Pino integration tests - pino not installed. Install with: npm install pino" + ); } }); let reforge: Reforge; @@ -88,23 +94,29 @@ describe("Pino Integration", () => { }); describe("createPinoLogger", () => { - (pinoInstalled ? it : it.skip)("creates a Pino logger with the correct initial level", async () => { - const logger = await createPinoLogger(reforge, "my.app.component"); + (pinoInstalled ? it : it.skip)( + "creates a Pino logger with the correct initial level", + async () => { + const logger = await createPinoLogger(reforge, "my.app.component"); - expect(logger).toBeDefined(); - expect(logger.level).toBe("debug"); - }); + expect(logger).toBeDefined(); + expect(logger.level).toBe("debug"); + } + ); - (pinoInstalled ? it : it.skip)("includes the logger name in options", async () => { - await createPinoLogger(reforge, "my.app.component"); + (pinoInstalled ? it : it.skip)( + "includes the logger name in options", + async () => { + await createPinoLogger(reforge, "my.app.component"); - expect(mockPinoFn).toHaveBeenCalledWith( - expect.objectContaining({ - name: "my.app.component", - level: "debug", - }) - ); - }); + expect(mockPinoFn).toHaveBeenCalledWith( + expect.objectContaining({ + name: "my.app.component", + level: "debug", + }) + ); + } + ); (pinoInstalled ? it : it.skip)("merges custom Pino options", async () => { const customOptions = { @@ -124,19 +136,79 @@ describe("Pino Integration", () => { ); }); - (pinoInstalled ? it : it.skip)("maps Reforge log levels to Pino levels correctly", async () => { + (pinoInstalled ? it : it.skip)( + "maps Reforge log levels to Pino levels correctly", + async () => { + const testCases = [ + { reforge: LogLevel.Trace, pino: "trace" }, + { reforge: LogLevel.Debug, pino: "debug" }, + { reforge: LogLevel.Info, pino: "info" }, + { reforge: LogLevel.Warn, pino: "warn" }, + { reforge: LogLevel.Error, pino: "error" }, + { reforge: LogLevel.Fatal, pino: "fatal" }, + ]; + + for (const testCase of testCases) { + // Update config for each level + reforge.setConfig( + [ + { + id: "1", + projectId: 1, + key: "my.logger.config", + changedBy: undefined, + rows: [ + { + properties: {}, + projectEnvId: projectEnvIdUnderTest, + values: [ + { + criteria: [], + value: { + logLevel: testCase.reforge, + }, + }, + ], + }, + ], + allowableValues: [], + configType: "LOG_LEVEL_V2" as any, + valueType: "LOG_LEVEL" as any, + sendToClientSdk: false, + }, + ], + projectEnvIdUnderTest, + new Map() + ); + + const logger = await createPinoLogger(reforge, "my.app.component"); + expect(logger.level).toBe(testCase.pino); + } + } + ); + }); + + describe("createPinoHook", () => { + (pinoInstalled ? it : it.skip)( + "returns a mixin function that includes the Reforge log level", + () => { + const mixin = createPinoHook(reforge, "my.app.component"); + const result = mixin(); + + expect(result).toHaveProperty("reforgeLogLevel"); + expect(result["reforgeLogLevel"]).toBe("debug"); + } + ); + + (pinoInstalled ? it : it.skip)( + "updates when Reforge log level changes", + () => { + const mixin = createPinoHook(reforge, "my.app.component"); - const testCases = [ - { reforge: LogLevel.Trace, pino: "trace" }, - { reforge: LogLevel.Debug, pino: "debug" }, - { reforge: LogLevel.Info, pino: "info" }, - { reforge: LogLevel.Warn, pino: "warn" }, - { reforge: LogLevel.Error, pino: "error" }, - { reforge: LogLevel.Fatal, pino: "fatal" }, - ]; + let result = mixin(); + expect(result["reforgeLogLevel"]).toBe("debug"); - for (const testCase of testCases) { - // Update config for each level + // Update the config reforge.setConfig( [ { @@ -152,7 +224,7 @@ describe("Pino Integration", () => { { criteria: [], value: { - logLevel: testCase.reforge, + logLevel: LogLevel.Error, }, }, ], @@ -168,61 +240,9 @@ describe("Pino Integration", () => { new Map() ); - const logger = await createPinoLogger(reforge, "my.app.component"); - expect(logger.level).toBe(testCase.pino); + result = mixin(); + expect(result["reforgeLogLevel"]).toBe("error"); } - }); - }); - - describe("createPinoHook", () => { - (pinoInstalled ? it : it.skip)("returns a mixin function that includes the Reforge log level", () => { - const mixin = createPinoHook(reforge, "my.app.component"); - const result = mixin(); - - expect(result).toHaveProperty("reforgeLogLevel"); - expect(result["reforgeLogLevel"]).toBe("debug"); - }); - - (pinoInstalled ? it : it.skip)("updates when Reforge log level changes", () => { - const mixin = createPinoHook(reforge, "my.app.component"); - - let result = mixin(); - expect(result["reforgeLogLevel"]).toBe("debug"); - - // Update the config - reforge.setConfig( - [ - { - id: "1", - projectId: 1, - key: "my.logger.config", - changedBy: undefined, - rows: [ - { - properties: {}, - projectEnvId: projectEnvIdUnderTest, - values: [ - { - criteria: [], - value: { - logLevel: LogLevel.Error, - }, - }, - ], - }, - ], - allowableValues: [], - configType: "LOG_LEVEL_V2" as any, - valueType: "LOG_LEVEL" as any, - sendToClientSdk: false, - }, - ], - projectEnvIdUnderTest, - new Map() - ); - - result = mixin(); - expect(result["reforgeLogLevel"]).toBe("error"); - }); + ); }); }); diff --git a/src/__tests__/integrations/winston.test.ts b/src/__tests__/integrations/winston.test.ts index 979c6ce..6a487c9 100644 --- a/src/__tests__/integrations/winston.test.ts +++ b/src/__tests__/integrations/winston.test.ts @@ -34,25 +34,31 @@ const mockCreateLogger = jest.fn((options: any) => { // Mock the winston module using doMock with moduleFactory if (winstonInstalled) { - jest.doMock("winston", () => ({ - createLogger: mockCreateLogger, - format: { - combine: jest.fn((...formats: any[]) => formats), - timestamp: jest.fn(), - errors: jest.fn(() => "errors"), - splat: jest.fn(), - json: jest.fn(), - }, - transports: { - Console: jest.fn(), - }, - }), { virtual: true }); + jest.doMock( + "winston", + () => ({ + createLogger: mockCreateLogger, + format: { + combine: jest.fn((...formats: any[]) => formats), + timestamp: jest.fn(), + errors: jest.fn(() => "errors"), + splat: jest.fn(), + json: jest.fn(), + }, + transports: { + Console: jest.fn(), + }, + }), + { virtual: true } + ); } describe("Winston Integration", () => { beforeAll(() => { if (!winstonInstalled) { - console.log("Skipping Winston integration tests - winston not installed. Install with: npm install winston"); + console.log( + "Skipping Winston integration tests - winston not installed. Install with: npm install winston" + ); } }); let reforge: Reforge; @@ -102,95 +108,109 @@ describe("Winston Integration", () => { }); describe("createWinstonLogger", () => { - (winstonInstalled ? it : it.skip)("creates a Winston logger with the correct initial level", async () => { - const logger = await createWinstonLogger(reforge, "my.app.component"); - - expect(logger).toBeDefined(); - expect(logger.level).toBe("debug"); - }); - - (winstonInstalled ? it : it.skip)("includes the logger name in defaultMeta", async () => { - await createWinstonLogger(reforge, "my.app.component"); + (winstonInstalled ? it : it.skip)( + "creates a Winston logger with the correct initial level", + async () => { + const logger = await createWinstonLogger(reforge, "my.app.component"); - expect(mockCreateLogger).toHaveBeenCalledWith( - expect.objectContaining({ - level: "debug", - defaultMeta: { loggerName: "my.app.component" }, - }) - ); - }); + expect(logger).toBeDefined(); + expect(logger.level).toBe("debug"); + } + ); - (winstonInstalled ? it : it.skip)("merges custom Winston options", async () => { - const customOptions = { - transports: [], - exitOnError: false, - }; + (winstonInstalled ? it : it.skip)( + "includes the logger name in defaultMeta", + async () => { + await createWinstonLogger(reforge, "my.app.component"); - await createWinstonLogger(reforge, "my.app.component", customOptions); + expect(mockCreateLogger).toHaveBeenCalledWith( + expect.objectContaining({ + level: "debug", + defaultMeta: { loggerName: "my.app.component" }, + }) + ); + } + ); - expect(mockCreateLogger).toHaveBeenCalledWith( - expect.objectContaining({ - level: "debug", + (winstonInstalled ? it : it.skip)( + "merges custom Winston options", + async () => { + const customOptions = { + transports: [], exitOnError: false, - }) - ); - }); + }; - (winstonInstalled ? it : it.skip)("maps Reforge log levels to Winston levels correctly", async () => { - - const testCases = [ - { reforge: LogLevel.Trace, winston: "debug" }, // Winston doesn't have trace - { reforge: LogLevel.Debug, winston: "debug" }, - { reforge: LogLevel.Info, winston: "info" }, - { reforge: LogLevel.Warn, winston: "warn" }, - { reforge: LogLevel.Error, winston: "error" }, - { reforge: LogLevel.Fatal, winston: "error" }, // Winston doesn't have fatal - ]; - - for (const testCase of testCases) { - // Update config for each level - reforge.setConfig( - [ - { - id: "1", - projectId: 1, - key: "my.logger.config", - changedBy: undefined, - rows: [ - { - properties: {}, - projectEnvId: projectEnvIdUnderTest, - values: [ - { - criteria: [], - value: { - logLevel: testCase.reforge, - }, - }, - ], - }, - ], - allowableValues: [], - configType: "LOG_LEVEL_V2" as any, - valueType: "LOG_LEVEL" as any, - sendToClientSdk: false, - }, - ], - projectEnvIdUnderTest, - new Map() + await createWinstonLogger(reforge, "my.app.component", customOptions); + + expect(mockCreateLogger).toHaveBeenCalledWith( + expect.objectContaining({ + level: "debug", + exitOnError: false, + }) ); + } + ); - const logger = await createWinstonLogger(reforge, "my.app.component"); - expect(logger.level).toBe(testCase.winston); + (winstonInstalled ? it : it.skip)( + "maps Reforge log levels to Winston levels correctly", + async () => { + const testCases = [ + { reforge: LogLevel.Trace, winston: "debug" }, // Winston doesn't have trace + { reforge: LogLevel.Debug, winston: "debug" }, + { reforge: LogLevel.Info, winston: "info" }, + { reforge: LogLevel.Warn, winston: "warn" }, + { reforge: LogLevel.Error, winston: "error" }, + { reforge: LogLevel.Fatal, winston: "error" }, // Winston doesn't have fatal + ]; + + for (const testCase of testCases) { + // Update config for each level + reforge.setConfig( + [ + { + id: "1", + projectId: 1, + key: "my.logger.config", + changedBy: undefined, + rows: [ + { + properties: {}, + projectEnvId: projectEnvIdUnderTest, + values: [ + { + criteria: [], + value: { + logLevel: testCase.reforge, + }, + }, + ], + }, + ], + allowableValues: [], + configType: "LOG_LEVEL_V2" as any, + valueType: "LOG_LEVEL" as any, + sendToClientSdk: false, + }, + ], + projectEnvIdUnderTest, + new Map() + ); + + const logger = await createWinstonLogger(reforge, "my.app.component"); + expect(logger.level).toBe(testCase.winston); + } } - }); + ); }); describe("createWinstonFormat", () => { - (winstonInstalled ? it : it.skip)("returns a Winston format function", async () => { - const format = await createWinstonFormat(reforge, "my.app.component"); + (winstonInstalled ? it : it.skip)( + "returns a Winston format function", + async () => { + const format = await createWinstonFormat(reforge, "my.app.component"); - expect(format).toBeDefined(); - }); + expect(format).toBeDefined(); + } + ); }); }); diff --git a/src/integrations/winston.ts b/src/integrations/winston.ts index 90a3696..a8a5f73 100644 --- a/src/integrations/winston.ts +++ b/src/integrations/winston.ts @@ -82,9 +82,15 @@ export async function createWinstonLogger( // Update level before each log call if ( typeof prop === "string" && - ["error", "warn", "info", "http", "verbose", "debug", "silly"].includes( - prop - ) + [ + "error", + "warn", + "info", + "http", + "verbose", + "debug", + "silly", + ].includes(prop) ) { updateLevel(); } diff --git a/src/resolver.ts b/src/resolver.ts index 3239608..e7b5eb7 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -362,7 +362,10 @@ class Resolver implements ResolverAPI { const result = this.get(key, contexts, LogLevelEnum.Debug, "ignore"); // Validate that the result is actually a LogLevel - if (typeof result === "string" && Object.values(LogLevelEnum).includes(result as LogLevel)) { + if ( + typeof result === "string" && + Object.values(LogLevelEnum).includes(result as LogLevel) + ) { return result as LogLevel; } From 2097a8612be538c800c97903824bcaa74ac0da27 Mon Sep 17 00:00:00 2001 From: James Kebinger Date: Tue, 4 Nov 2025 12:55:44 -0500 Subject: [PATCH 6/6] docs: add link to full documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add prominent link to https://docs.reforge.com/docs/sdks/node at the top of README 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f09e0c7..7795b29 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Reforge Node.js client +📚 **[Full Documentation](https://docs.reforge.com/docs/sdks/node)** + --- Install the client