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/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/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..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 @@ -33,6 +35,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 +94,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 +113,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..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", @@ -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": [ @@ -37,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", @@ -59,5 +75,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..4a738d6 --- /dev/null +++ b/src/__tests__/integrations/pino.test.ts @@ -0,0 +1,248 @@ +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..6a487c9 --- /dev/null +++ b/src/__tests__/integrations/winston.test.ts @@ -0,0 +1,216 @@ +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..89a60dc --- /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-expect-error - 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..a8a5f73 --- /dev/null +++ b/src/integrations/winston.ts @@ -0,0 +1,162 @@ +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-expect-error - 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-expect-error - 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..e7b5eb7 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,36 @@ 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", diff --git a/yarn.lock b/yarn.lock index 8516573..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" @@ -942,6 +943,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 @@ -1122,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" @@ -5717,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"