diff --git a/.gitignore b/.gitignore index a8543ed..b005745 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ types/ *.tgz tsconfig.tsbuildinfo .envrc + +# IDE and tool directories +.idea/ +.claude/ +.yarn/ diff --git a/.yarnrc.yml b/.yarnrc.yml index 45a70b4..c93a20d 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,3 +1,3 @@ nodeLinker: node-modules npmRegistryServer: 'https://registry.npmjs.org' -npmAuthToken: '${NPM_AUTH_TOKEN}' +npmAuthToken: '${NPM_AUTH_TOKEN-}' diff --git a/README.md b/README.md index 33cfd61..db63de2 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ setTimeout(ping, reforge.get('ping-delay')); | `isEnabled` | `reforge.isEnabled("new-logo")` | returns a boolean (default `false`) if a feature is enabled based on the current context | | `get` | `reforge.get('retry-count')` | returns the value of a flag or config evaluated in the current context | | `getDuration` | `reforge.getDuration('http.timeout')` | returns a duration object `{seconds: number, ms: number}` | +| `getLogLevel` | `reforge.getLogLevel("my.app.logger")` | returns a `LogLevel` enum value for the specified logger name | +| `logger` | `reforge.logger.info("message")` | log messages with dynamic log level control (see below for all methods) | | `loaded` | `if (reforge.loaded) { ... }` | a boolean indicating whether reforge content has loaded | | `shouldLog` | `if (reforge.shouldLog(...)) {` | returns a boolean indicating whether the proposed log level is valid for the current context | | `poll` | `reforge.poll({frequencyInMs})` | starts polling every `frequencyInMs` ms. | @@ -91,10 +93,12 @@ setTimeout(ping, reforge.get('ping-delay')); | `desiredLevel` | string | INFO | No | | `defaultLevel` | string | ERROR | No | -If you've configured a level value for `loggerName` (or a parent in the dot-notation hierarchy like -"my.corp.widgets") then that value will be used for comparison against the `desiredLevel`. If no -configured level is found in the hierarchy for `loggerName` then the provided `defaultLevel` will be -compared against `desiredLevel`. +If you've configured a level value for the exact `loggerName` (as `log-level.{loggerName}`), that +value will be used for comparison against the `desiredLevel`. If no configured level is found for +the exact `loggerName`, then the provided `defaultLevel` will be compared against `desiredLevel`. + +**Note:** `shouldLog` does NOT traverse the logger name hierarchy. It only checks for an exact match +of `log-level.{loggerName}`. If `desiredLevel` is greater than or equal to the comparison severity, then `shouldLog` returns true. If the `desiredLevel` is less than the comparison severity, then `shouldLog` will return @@ -112,10 +116,190 @@ if (shouldLog({ loggerName, desiredLevel, defaultLevel })) { } ``` -If no log level value is configured in Reforge for "my.corp.widgets.modal" or higher in the -hierarchy, then the `console.info` will not happen. If the value is configured and is INFO or more +If no log level value is configured in Reforge for the exact key +`"log-level.my.corp.widgets.modal"`, then the `defaultLevel` ("ERROR") will be used and the +`console.info` will not happen. If the value is configured for that exact key and is INFO or more verbose, the `console.info` will happen. +## `getLogLevel()` + +`getLogLevel` provides a simpler way to get log levels for dynamic logging. It returns a `LogLevel` +enum value from a configured key. + +### Configuration + +You can optionally specify a custom logger key during initialization (default is +`"log-levels.default"`): + +```javascript +await reforge.init({ + sdkKey: "1234", + context: new Context({ + /* ... */ + }), + loggerKey: "my.custom.log.config", // optional, defaults to "log-levels.default" +}); +``` + +### Usage + +```javascript +import { reforge, LogLevel } from "@reforge-com/javascript"; + +const loggerName = "my.app.widgets.modal"; +const level = reforge.getLogLevel(loggerName); + +// level is a LogLevel enum value +if (level === LogLevel.DEBUG || level === LogLevel.TRACE) { + console.debug("Debug information..."); +} +``` + +### How it works + +When you call `getLogLevel(loggerName)`, the method: + +1. Looks up the configured logger key (default: `"log-levels.default"`) +2. Returns the appropriate `LogLevel` enum value (TRACE, DEBUG, INFO, WARN, ERROR, or FATAL) +3. Returns `LogLevel.DEBUG` as the default if no configuration is found + +**Note:** The `loggerName` parameter is currently only used for potential telemetry/logging +purposes. All loggers share the same configured log level from the logger key. For per-logger log +levels, use `shouldLog()` with individual `log-level.{loggerName}` configs. + +## `logger` - Simple Logging Methods + +The `reforge.logger` object provides convenient methods for logging at different levels. These +methods automatically check the configured log level and only output to the console when +appropriate. + +### Available Methods + +```javascript +import { reforge } from "@reforge-com/javascript"; + +// Configure the log level +await reforge.init({ + sdkKey: "1234", + context: new Context({ + /* ... */ + }), + loggerKey: "log-levels.default", // optional +}); + +reforge.hydrate({ "log-levels.default": "INFO" }); + +// Use the logger methods +reforge.logger.trace("Trace message"); // Will not log (below INFO) +reforge.logger.debug("Debug message"); // Will not log (below INFO) +reforge.logger.info("Info message"); // Will log +reforge.logger.warn("Warning message"); // Will log +reforge.logger.error("Error message"); // Will log +reforge.logger.fatal("Fatal message"); // Will log +``` + +### How it Works + +Each logger method: + +1. Checks the configured log level from the logger key (default: `"log-levels.default"`) +2. Compares the message level against the configured level +3. Only outputs to the console if the level is enabled + +**Console Method Mapping:** + +- `trace()` and `debug()` → `console.debug()` +- `info()` → `console.info()` +- `warn()` → `console.warn()` +- `error()` and `fatal()` → `console.error()` + +### Example + +```javascript +import { reforge, Context } from "@reforge-com/javascript"; + +await reforge.init({ + sdkKey: "your-key", + context: new Context({ user: { id: "123" } }), +}); + +// Set log level to WARN +reforge.hydrate({ "log-levels.default": "WARN" }); + +reforge.logger.debug("Debug details"); // Not logged +reforge.logger.info("Process started"); // Not logged +reforge.logger.warn("Low disk space"); // Logged to console +reforge.logger.error("Failed to save"); // Logged to console +``` + +### LogLevel enum values + +- `LogLevel.TRACE` (1) - Most verbose +- `LogLevel.DEBUG` (2) +- `LogLevel.INFO` (3) +- `LogLevel.WARN` (5) +- `LogLevel.ERROR` (6) +- `LogLevel.FATAL` (9) - Least verbose + +### Comparing LogLevel values + +Since `LogLevel` is a string enum, you can't use `<=` directly. Use the provided helper functions: + +```javascript +import { reforge, LogLevel, shouldLogAtLevel, getLogLevelSeverity } from "@reforge-com/javascript"; + +const configuredLevel = reforge.getLogLevel("my.app.logger"); + +// Option 1: Use shouldLogAtLevel helper (recommended) +if (shouldLogAtLevel(configuredLevel, LogLevel.DEBUG)) { + console.debug("Debug message"); +} + +// Option 2: Compare severity values +if (getLogLevelSeverity(configuredLevel) <= getLogLevelSeverity(LogLevel.INFO)) { + console.info("Info message"); +} +``` + +### Example integration + +```javascript +import { reforge, LogLevel, shouldLogAtLevel } from "@reforge-com/javascript"; + +class Logger { + constructor(name) { + this.name = name; + } + + debug(message) { + const level = reforge.getLogLevel(this.name); + if (shouldLogAtLevel(level, LogLevel.DEBUG)) { + console.debug(`[${this.name}] ${message}`); + } + } + + info(message) { + const level = reforge.getLogLevel(this.name); + if (shouldLogAtLevel(level, LogLevel.INFO)) { + console.info(`[${this.name}] ${message}`); + } + } + + error(message) { + const level = reforge.getLogLevel(this.name); + if (shouldLogAtLevel(level, LogLevel.ERROR)) { + console.error(`[${this.name}] ${message}`); + } + } +} + +// Usage +const logger = new Logger("my.app.components.modal"); +logger.debug("Modal opened"); // Only logs if DEBUG level is enabled for this logger +logger.info("User action completed"); // Only logs if INFO level or more verbose is enabled +logger.error("Failed to save"); // Logs for ERROR level or more verbose +``` + ## `poll()` After `reforge.init()`, you can start polling. Polling uses the context you defined in `init` by diff --git a/index.ts b/index.ts index c76ac79..e55db04 100644 --- a/index.ts +++ b/index.ts @@ -1,10 +1,21 @@ import { reforge, Reforge, ReforgeInitParams, ReforgeBootstrap } from "./src/reforge"; import { Config } from "./src/config"; import Context from "./src/context"; +import { LogLevel, getLogLevelSeverity, shouldLogAtLevel } from "./src/logger"; // eslint-disable-next-line @typescript-eslint/no-var-requires const { version } = require("./package.json"); -export { reforge, Reforge, ReforgeInitParams, Config, Context, version }; +export { + reforge, + Reforge, + ReforgeInitParams, + Config, + Context, + LogLevel, + getLogLevelSeverity, + shouldLogAtLevel, + version, +}; export { ReforgeBootstrap }; diff --git a/package.json b/package.json index 48d40ec..614d257 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "packageManager": "yarn@4.9.2", "name": "@reforge-com/javascript", - "version": "0.0.3", + "version": "0.0.4", "description": "Feature Flags & Dynamic Configuration as a Service", "main": "dist/index.cjs", "module": "dist/index.mjs", diff --git a/src/logger.ts b/src/logger.ts index 760c779..0ec1b9b 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -31,28 +31,38 @@ export interface ShouldLogParams { get: (key: K) => TypedFrontEndConfigurationRaw[K]; } +// LogLevel enum for public API +export enum LogLevel { + TRACE = "TRACE", + DEBUG = "DEBUG", + INFO = "INFO", + WARN = "WARN", + ERROR = "ERROR", + FATAL = "FATAL", +} + +// Get the numeric severity value for a LogLevel (lower = more verbose) +export const getLogLevelSeverity = (level: LogLevel): number => WORD_LEVEL_LOOKUP[level]; + +// Check if a log at desiredLevel should be logged given the configured level +// Returns true if desiredLevel is at or above the configured level's severity +export const shouldLogAtLevel = (configuredLevel: LogLevel, desiredLevel: LogLevel): boolean => + WORD_LEVEL_LOOKUP[configuredLevel] <= WORD_LEVEL_LOOKUP[desiredLevel]; + export const shouldLog = ({ loggerName, desiredLevel, defaultLevel, get, }: ShouldLogParams): boolean => { - let loggerNameWithPrefix = `${PREFIX}.${loggerName}`; - - while (loggerNameWithPrefix.length > 0) { - const resolvedLevel = get(loggerNameWithPrefix); - - if (resolvedLevel) { - return ( - WORD_LEVEL_LOOKUP[resolvedLevel.toString().toUpperCase() as LogLevelWord] <= desiredLevel - ); - } + const loggerNameWithPrefix = `${PREFIX}.${loggerName}`; - if (loggerNameWithPrefix.indexOf(".") === -1) { - break; - } + const resolvedLevel = get(loggerNameWithPrefix); - loggerNameWithPrefix = loggerNameWithPrefix.slice(0, loggerNameWithPrefix.lastIndexOf(".")); + if (resolvedLevel) { + return ( + WORD_LEVEL_LOOKUP[resolvedLevel.toString().toUpperCase() as LogLevelWord] <= desiredLevel + ); } return defaultLevel <= desiredLevel; diff --git a/src/reforge.test.ts b/src/reforge.test.ts index b299abd..b63b538 100644 --- a/src/reforge.test.ts +++ b/src/reforge.test.ts @@ -1,5 +1,12 @@ import fetchMock, { enableFetchMocks } from "jest-fetch-mock"; -import { Reforge, Context, type ReforgeBootstrap } from "../index"; +import { + Reforge, + Context, + LogLevel, + shouldLogAtLevel, + getLogLevelSeverity, + type ReforgeBootstrap, +} from "../index"; import { Contexts } from "./types"; import { type EvaluationPayload } from "./config"; import { DEFAULT_TIMEOUT } from "./apiHelpers"; @@ -400,19 +407,17 @@ describe("shouldLog", () => { ).toBe(false); }); - test("traverses the hierarchy to get the closest level for the loggerName", () => { - const loggerName = "some.test.name.with.more.levels"; - + test("does not traverse hierarchy, only checks exact logger name", () => { reforge.hydrate({ - "log-level.some.test.name": "TRACE", "log-level.some.test": "DEBUG", "log-level.irrelevant": "ERROR", }); + // Exact match should use the configured level expect( reforge.shouldLog({ - loggerName, - desiredLevel: ReforgeLogLevel.Trace, + loggerName: "some.test", + desiredLevel: ReforgeLogLevel.Debug, defaultLevel: ReforgeLogLevel.Error, }) ).toEqual(true); @@ -425,32 +430,34 @@ describe("shouldLog", () => { }) ).toEqual(false); + // No match for longer name, should use default expect( reforge.shouldLog({ - loggerName: "some.test", - desiredLevel: ReforgeLogLevel.Debug, + loggerName: "some.test.name.with.more.levels", + desiredLevel: ReforgeLogLevel.Error, defaultLevel: ReforgeLogLevel.Error, }) ).toEqual(true); expect( reforge.shouldLog({ - loggerName: "some.test", - desiredLevel: ReforgeLogLevel.Info, + loggerName: "some.test.name.with.more.levels", + desiredLevel: ReforgeLogLevel.Debug, defaultLevel: ReforgeLogLevel.Error, }) - ).toEqual(true); + ).toEqual(false); }); - it("can use the root log level setting if nothing is found in the hierarchy", () => { + it("uses default level when no exact match is found", () => { reforge.hydrate({ - "log-level": "INFO", + "log-level.other": "INFO", }); + // No config for "some.test", should use default ERROR expect( reforge.shouldLog({ loggerName: "some.test", - desiredLevel: ReforgeLogLevel.Info, + desiredLevel: ReforgeLogLevel.Error, defaultLevel: ReforgeLogLevel.Error, }) ).toEqual(true); @@ -458,13 +465,325 @@ describe("shouldLog", () => { expect( reforge.shouldLog({ loggerName: "some.test", - desiredLevel: ReforgeLogLevel.Debug, + desiredLevel: ReforgeLogLevel.Info, defaultLevel: ReforgeLogLevel.Error, }) ).toEqual(false); }); }); +describe("getLogLevel", () => { + test("returns DEBUG by default when no config is found", () => { + const level = reforge.getLogLevel("my.app.logger"); + expect(level).toBe(LogLevel.DEBUG); + }); + + test("returns the configured log level from the default logger key", () => { + reforge.hydrate({ + "log-levels.default": "INFO", + }); + + const level = reforge.getLogLevel("my.app.logger"); + expect(level).toBe(LogLevel.INFO); + }); + + test("returns all possible LogLevel enum values", () => { + const logLevels = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"]; + + logLevels.forEach((levelString) => { + reforge.hydrate({ + "log-levels.default": levelString, + }); + + const level = reforge.getLogLevel("test.logger"); + expect(level).toBe(LogLevel[levelString as keyof typeof LogLevel]); + }); + }); + + test("handles lowercase log level values", () => { + reforge.hydrate({ + "log-levels.default": "info", + }); + + const level = reforge.getLogLevel("my.app.logger"); + expect(level).toBe(LogLevel.INFO); + }); + + test("handles mixed case log level values", () => { + reforge.hydrate({ + "log-levels.default": "WaRn", + }); + + const level = reforge.getLogLevel("my.app.logger"); + expect(level).toBe(LogLevel.WARN); + }); + + test("returns DEBUG for invalid log level values", () => { + reforge.hydrate({ + "log-levels.default": "INVALID", + }); + + const level = reforge.getLogLevel("my.app.logger"); + expect(level).toBe(LogLevel.DEBUG); + }); + + test("returns DEBUG when config value is not a string", () => { + reforge.hydrate({ + "log-levels.default": 123, + }); + + const level = reforge.getLogLevel("my.app.logger"); + expect(level).toBe(LogLevel.DEBUG); + }); + + test("uses custom logger key when specified during init", async () => { + const data = { evaluations: {} }; + fetchMock.mockResponse(JSON.stringify(data)); + + await reforge.init({ + ...defaultTestInitParams, + loggerKey: "custom.logger.config", + }); + + reforge.hydrate({ + "custom.logger.config": "ERROR", + "log-levels.default": "DEBUG", + }); + + const level = reforge.getLogLevel("my.app.logger"); + expect(level).toBe(LogLevel.ERROR); + }); + + test("can be used for dynamic logging decisions", () => { + reforge.hydrate({ + "log-levels.default": "INFO", + }); + + const loggerName = "my.app.components.modal"; + const level = reforge.getLogLevel(loggerName); + + // Should return INFO + expect(level).toBe(LogLevel.INFO); + + // Example of checking specific log levels + expect(level).not.toBe(LogLevel.DEBUG); + expect(level).toBe(LogLevel.INFO); + expect(level).not.toBe(LogLevel.ERROR); + + // To compare log level severity, you would need to use shouldLog or compare against the enum + expect(level === LogLevel.TRACE || level === LogLevel.DEBUG).toBe(false); + expect(level === LogLevel.INFO || level === LogLevel.WARN || level === LogLevel.ERROR).toBe( + true + ); + }); + + test("all logger names return the same configured level", () => { + reforge.hydrate({ + "log-levels.default": "DEBUG", + }); + + const level1 = reforge.getLogLevel("app.module1"); + const level2 = reforge.getLogLevel("app.module2"); + const level3 = reforge.getLogLevel("app.module3.submodule"); + + // All logger names use the same configured key, so all return the same level + expect(level1).toBe(LogLevel.DEBUG); + expect(level2).toBe(LogLevel.DEBUG); + expect(level3).toBe(LogLevel.DEBUG); + }); +}); + +describe("logger", () => { + beforeEach(() => { + jest.spyOn(console, "debug").mockImplementation(); + jest.spyOn(console, "info").mockImplementation(); + jest.spyOn(console, "warn").mockImplementation(); + jest.spyOn(console, "error").mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("logs when level is enabled", () => { + reforge.hydrate({ + "log-levels.default": "DEBUG", + }); + + reforge.logger.debug("Debug message"); + reforge.logger.info("Info message"); + reforge.logger.warn("Warn message"); + reforge.logger.error("Error message"); + + /* eslint-disable no-console */ + expect(console.debug).toHaveBeenCalledWith("Debug message"); + expect(console.info).toHaveBeenCalledWith("Info message"); + expect(console.warn).toHaveBeenCalledWith("Warn message"); + expect(console.error).toHaveBeenCalledWith("Error message"); + /* eslint-enable no-console */ + }); + + test("does not log when level is disabled", () => { + reforge.hydrate({ + "log-levels.default": "ERROR", + }); + + reforge.logger.trace("Trace message"); + reforge.logger.debug("Debug message"); + reforge.logger.info("Info message"); + reforge.logger.warn("Warn message"); + reforge.logger.error("Error message"); + + /* eslint-disable no-console */ + expect(console.debug).not.toHaveBeenCalled(); + expect(console.info).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith("Error message"); + /* eslint-enable no-console */ + }); + + test("respects INFO level configuration", () => { + reforge.hydrate({ + "log-levels.default": "INFO", + }); + + reforge.logger.trace("Trace message"); + reforge.logger.debug("Debug message"); + reforge.logger.info("Info message"); + reforge.logger.warn("Warn message"); + reforge.logger.error("Error message"); + reforge.logger.fatal("Fatal message"); + + /* eslint-disable no-console */ + expect(console.debug).not.toHaveBeenCalled(); + expect(console.info).toHaveBeenCalledWith("Info message"); + expect(console.warn).toHaveBeenCalledWith("Warn message"); + expect(console.error).toHaveBeenCalledWith("Error message"); + expect(console.error).toHaveBeenCalledWith("Fatal message"); + /* eslint-enable no-console */ + }); + + test("trace uses debug console method", () => { + reforge.hydrate({ + "log-levels.default": "TRACE", + }); + + reforge.logger.trace("Trace message"); + + /* eslint-disable no-console */ + expect(console.debug).toHaveBeenCalledWith("Trace message"); + /* eslint-enable no-console */ + }); + + test("fatal uses error console method", () => { + reforge.hydrate({ + "log-levels.default": "TRACE", + }); + + reforge.logger.fatal("Fatal message"); + + /* eslint-disable no-console */ + expect(console.error).toHaveBeenCalledWith("Fatal message"); + /* eslint-enable no-console */ + }); + + test("uses custom logger key", async () => { + const data = { evaluations: {} }; + fetchMock.mockResponse(JSON.stringify(data)); + + await reforge.init({ + ...defaultTestInitParams, + loggerKey: "custom.log.level", + }); + + reforge.hydrate({ + "custom.log.level": "WARN", + "log-levels.default": "DEBUG", + }); + + reforge.logger.info("Info message"); + reforge.logger.warn("Warn message"); + + /* eslint-disable no-console */ + expect(console.info).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith("Warn message"); + /* eslint-enable no-console */ + }); +}); + +describe("getLogLevelSeverity", () => { + test("returns correct numeric values for all log levels", () => { + expect(getLogLevelSeverity(LogLevel.TRACE)).toBe(1); + expect(getLogLevelSeverity(LogLevel.DEBUG)).toBe(2); + expect(getLogLevelSeverity(LogLevel.INFO)).toBe(3); + expect(getLogLevelSeverity(LogLevel.WARN)).toBe(4); + expect(getLogLevelSeverity(LogLevel.ERROR)).toBe(5); + expect(getLogLevelSeverity(LogLevel.FATAL)).toBe(6); + }); + + test("can be used to compare log level severity", () => { + expect(getLogLevelSeverity(LogLevel.TRACE)).toBeLessThan(getLogLevelSeverity(LogLevel.DEBUG)); + expect(getLogLevelSeverity(LogLevel.DEBUG)).toBeLessThan(getLogLevelSeverity(LogLevel.INFO)); + expect(getLogLevelSeverity(LogLevel.INFO)).toBeLessThan(getLogLevelSeverity(LogLevel.WARN)); + expect(getLogLevelSeverity(LogLevel.WARN)).toBeLessThan(getLogLevelSeverity(LogLevel.ERROR)); + expect(getLogLevelSeverity(LogLevel.ERROR)).toBeLessThan(getLogLevelSeverity(LogLevel.FATAL)); + }); +}); + +describe("shouldLogAtLevel", () => { + test("returns true when desired level is at or above configured level", () => { + // Configured level is INFO (3), should log INFO (3) and above + expect(shouldLogAtLevel(LogLevel.INFO, LogLevel.TRACE)).toBe(false); + expect(shouldLogAtLevel(LogLevel.INFO, LogLevel.DEBUG)).toBe(false); + expect(shouldLogAtLevel(LogLevel.INFO, LogLevel.INFO)).toBe(true); + expect(shouldLogAtLevel(LogLevel.INFO, LogLevel.WARN)).toBe(true); + expect(shouldLogAtLevel(LogLevel.INFO, LogLevel.ERROR)).toBe(true); + expect(shouldLogAtLevel(LogLevel.INFO, LogLevel.FATAL)).toBe(true); + }); + + test("returns false when desired level is below configured level", () => { + // Configured level is ERROR (6), should not log INFO (3) + expect(shouldLogAtLevel(LogLevel.ERROR, LogLevel.INFO)).toBe(false); + expect(shouldLogAtLevel(LogLevel.ERROR, LogLevel.WARN)).toBe(false); + }); + + test("works correctly for TRACE (most verbose)", () => { + // TRACE allows all log levels + expect(shouldLogAtLevel(LogLevel.TRACE, LogLevel.TRACE)).toBe(true); + expect(shouldLogAtLevel(LogLevel.TRACE, LogLevel.DEBUG)).toBe(true); + expect(shouldLogAtLevel(LogLevel.TRACE, LogLevel.INFO)).toBe(true); + expect(shouldLogAtLevel(LogLevel.TRACE, LogLevel.WARN)).toBe(true); + expect(shouldLogAtLevel(LogLevel.TRACE, LogLevel.ERROR)).toBe(true); + expect(shouldLogAtLevel(LogLevel.TRACE, LogLevel.FATAL)).toBe(true); + }); + + test("works correctly for FATAL (least verbose)", () => { + // FATAL only allows FATAL logs + expect(shouldLogAtLevel(LogLevel.FATAL, LogLevel.TRACE)).toBe(false); + expect(shouldLogAtLevel(LogLevel.FATAL, LogLevel.DEBUG)).toBe(false); + expect(shouldLogAtLevel(LogLevel.FATAL, LogLevel.INFO)).toBe(false); + expect(shouldLogAtLevel(LogLevel.FATAL, LogLevel.WARN)).toBe(false); + expect(shouldLogAtLevel(LogLevel.FATAL, LogLevel.ERROR)).toBe(false); + expect(shouldLogAtLevel(LogLevel.FATAL, LogLevel.FATAL)).toBe(true); + }); + + test("can be used with getLogLevel for dynamic logging", () => { + reforge.hydrate({ + "log-levels.default": "INFO", + }); + + const level = reforge.getLogLevel("my.app.logger"); + + // Should allow INFO and above + expect(shouldLogAtLevel(level, LogLevel.TRACE)).toBe(false); + expect(shouldLogAtLevel(level, LogLevel.DEBUG)).toBe(false); + expect(shouldLogAtLevel(level, LogLevel.INFO)).toBe(true); + expect(shouldLogAtLevel(level, LogLevel.WARN)).toBe(true); + expect(shouldLogAtLevel(level, LogLevel.ERROR)).toBe(true); + expect(shouldLogAtLevel(level, LogLevel.FATAL)).toBe(true); + }); +}); + describe("updateContext", () => { it("updates the context and reloads", async () => { let invokedUrl: string | undefined; diff --git a/src/reforge.ts b/src/reforge.ts index 140d268..0646d88 100644 --- a/src/reforge.ts +++ b/src/reforge.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { v4 as uuid } from "uuid"; import { Config, EvaluationPayload, RawConfigWithoutTypes } from "./config"; @@ -10,7 +11,13 @@ import type { import Context from "./context"; import { EvaluationSummaryAggregator } from "./evaluationSummaryAggregator"; import Loader, { CollectContextModeType } from "./loader"; -import { PREFIX as loggerPrefix, shouldLog, ShouldLogParams } from "./logger"; +import { + PREFIX as loggerPrefix, + shouldLog, + ShouldLogParams, + LogLevel, + shouldLogAtLevel, +} from "./logger"; import TelemetryUploader from "./telemetryUploader"; import { LoggerAggregator } from "./loggerAggregator"; // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -39,6 +46,7 @@ export type ReforgeInitParams = { collectContextMode?: CollectContextModeType; clientNameString?: string; clientVersionString?: string; + loggerKey?: string; }; type PollStatus = @@ -49,6 +57,72 @@ type PollStatus = type PublicShouldLogParams = Omit; +// Forward declaration for ReforgeLogger +// eslint-disable-next-line @typescript-eslint/no-use-before-define +class ReforgeLogger { + // eslint-disable-next-line no-use-before-define + private reforge: Reforge; + + // eslint-disable-next-line no-use-before-define + constructor(reforge: Reforge) { + this.reforge = reforge; + } + + private log(message: string, level: LogLevel): void { + const configuredLevel = this.reforge.getLogLevel(""); + + if (shouldLogAtLevel(configuredLevel, level)) { + switch (level) { + case LogLevel.TRACE: + case LogLevel.DEBUG: + // eslint-disable-next-line no-console + console.debug(message); + break; + case LogLevel.INFO: + // eslint-disable-next-line no-console + console.info(message); + break; + case LogLevel.WARN: + // eslint-disable-next-line no-console + console.warn(message); + break; + case LogLevel.ERROR: + case LogLevel.FATAL: + // eslint-disable-next-line no-console + console.error(message); + break; + default: + // eslint-disable-next-line no-console + console.error(message); + } + } + } + + trace(message: string): void { + this.log(message, LogLevel.TRACE); + } + + debug(message: string): void { + this.log(message, LogLevel.DEBUG); + } + + info(message: string): void { + this.log(message, LogLevel.INFO); + } + + warn(message: string): void { + this.log(message, LogLevel.WARN); + } + + error(message: string): void { + this.log(message, LogLevel.ERROR); + } + + fatal(message: string): void { + this.log(message, LogLevel.FATAL); + } +} + export class Reforge { private _configs: { [key: string]: Config } = {}; @@ -80,6 +154,14 @@ export class Reforge { private _context: Context = new Context({}); + private _loggerKey = "log-levels.default"; + + public logger: ReforgeLogger; + + constructor() { + this.logger = new ReforgeLogger(this); + } + async init({ sdkKey, context: providedContext, @@ -92,6 +174,7 @@ export class Reforge { collectContextMode = "PERIODIC_EXAMPLE", clientNameString = "sdk-javascript", clientVersionString = version, + loggerKey = "log-levels.default", }: ReforgeInitParams) { const context = providedContext ?? this.context; @@ -100,6 +183,7 @@ export class Reforge { } this._context = context; + this._loggerKey = loggerKey; this.clientNameString = clientNameString; const clientNameAndVersionString = `${clientNameString}-${clientVersionString}`; @@ -361,6 +445,20 @@ export class Reforge { return shouldLog({ ...args, get: this.get.bind(this) }); } + getLogLevel(_loggerName: string): LogLevel { + const value = this.get(this._loggerKey); + + if (value && typeof value === "string") { + const upperValue = value.toUpperCase(); + if (upperValue in LogLevel) { + return LogLevel[upperValue as keyof typeof LogLevel]; + } + } + + // Default to DEBUG if no config found or invalid value + return LogLevel.DEBUG; + } + isCollectingEvaluationSummaries(): boolean { return this.collectEvaluationSummaries; }