From 8741ba72dc0a6ffe8f5d9e9fa6c85416851e7304 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Tue, 21 Jul 2026 15:23:43 -0700 Subject: [PATCH] Decouple logger levels from deployment environment --- README.md | 27 +++++--- docs/concepts/architecture.mdx | 17 +++-- docs/configuration.mdx | 33 ++++++---- docs/guides/logging.mdx | 20 +++--- docs/reference/api.mdx | 8 ++- docs/reference/environment.mdx | 25 ++++---- src/logger.ts | 56 +++++++++++----- src/setup.ts | 8 ++- tests/integration/otel-collector.test.ts | 9 +-- tests/logger.test.ts | 81 ++++++++++++++++++++---- tests/setup.test.ts | 16 +++++ 11 files changed, 217 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index a1aaad4..f16035d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Vanilla OTel works, but the setup is verbose, the logger plumbing is awkward, an - **`setupOtel()`** — idempotent one-call bootstrap for traces + logs + metrics. Honors standard `OTEL_EXPORTER_OTLP_*` env vars. - **`otel.getMeter(name)`** — creates standard OpenTelemetry instruments from this setup's meter provider, with identical behavior in global and scoped mode. -- **`createLogger(module)`** — structured logger that writes to both the OTel logger provider and `console`, with automatic trace correlation and exception capture. Every level (`debug`/`info`/`warn`/`error`) accepts `attrs` **and** an `error`, and is gated by a configurable `LOG_LEVEL`. +- **`createLogger(module)`** — structured logger that writes to both the OTel logger provider and `console`, with automatic trace correlation and exception capture. Every level (`debug`/`info`/`warn`/`error`) accepts `attrs` **and** an `error`, and shares one configurable level gate. - **`withSpan(name, attrs?, fn)`** — wrap any sync or async function in a span; errors are recorded and PII in the error message is scrubbed before being attached to span status. - **Automatic `fetch` tracing** — `setupOtel()` instruments outbound `fetch` so every request gets a CLIENT span and W3C trace-context headers. On **Node** it uses the official `@opentelemetry/instrumentation-undici`; on **Bun** — whose native fetch emits nothing for the standard `diagnostics_channel`-based instrumentations — it wraps `globalThis.fetch`. Pass `instrumentFetch: { mode: "global" }` to force the wrap on both for identical spans. - **`sanitizeEmail` / `sanitizePhone` / `sanitizeErrorMessage`** — PII helpers you can reuse anywhere. @@ -98,8 +98,8 @@ attribute guidance, and scoped mode. | `instrumentFetch(options?): FetchInstrumentation` | Low-level wrap of `globalThis.fetch` for CLIENT spans + W3C propagation. Returns `{ unpatch() }`. `setupOtel` calls this on Bun; on Node it prefers native undici. | | `createInstrumentedFetch(baseFetch?, options?): typeof fetch` | Returns a NEW instrumented fetch (CLIENT spans + W3C propagation) wrapping `baseFetch` (default `globalThis.fetch`) without touching the global. For SDKs that take a `fetch` option. | | `createLogger(module): PhotonLogger` | Returns `{ info, warn, error, debug }`. Each call emits to OTel + `console`, correlates to active span. | -| `setLogLevel(level): void` | Set the minimum level emitted (`debug`/`info`/`warn`/`error`/`silent`). `LOG_LEVEL` env still wins. | -| `getLogLevel(): LogLevel` | Current effective level after env / override / default resolution. | +| `setLogLevel(level): void` | Set the minimum level emitted (`debug`/`info`/`warn`/`error`/`silent`). Programmatic configuration wins over `LOG_LEVEL`. | +| `getLogLevel(): LogLevel` | Current effective level after programmatic / env / default resolution. | | `withSpan(name, fn)` | Wraps `fn` (sync or async) in a span. Records exceptions and scrubs PII in error messages. | | `withSpan(name, attrs, fn)` | Same as above but attaches `attrs` to the span. | | `sanitizeEmail(input)` | Masks an email: `foo.bar@example.com` → `fo***@e***.com`. | @@ -134,22 +134,29 @@ sinks share one level gate. Logs below the active level are dropped from **both** OTLP and the console. The level is resolved fresh on every call, so changes take effect immediately: -1. `LOG_LEVEL` env var (`debug` | `info` | `warn` | `error` | `silent`) — wins if set. -2. `setLogLevel(level)` or `setupOtel({ logLevel })`. -3. Default: `debug` in development (`DEPLOYMENT_ENV` unset or `development`), `info` otherwise. +1. `setLogLevel(level)` or `setupOtel({ logLevel })`. +2. `LOG_LEVEL` env var (`debug` | `info` | `warn` | `error` | `silent`). +3. Default: `info`. + +Logger configuration is independent of `DEPLOYMENT_ENV`; that variable only supplies +OpenTelemetry resource metadata. Environment values are trimmed and case-insensitive. An +invalid `LOG_LEVEL` falls back to `info` and warns once per distinct value, while an invalid +programmatic value throws a `TypeError` immediately (useful for untyped JavaScript callers). ```ts import { setLogLevel } from "@photon-ai/otel"; setLogLevel("warn"); // debug + info now suppressed everywhere -// or set LOG_LEVEL=warn in the environment, which overrides the call above +// LOG_LEVEL is used only when no programmatic level has been set ``` `"silent"` suppresses everything, including errors. ## Configuration -Standard OpenTelemetry env vars always take precedence over `SetupOtelOptions`: +Standard OpenTelemetry exporter env vars take precedence over endpoint and +header options. Logger and deployment metadata variables follow the behavior +listed below: | Variable | Effect | | ----------------------------------------- | ------------------------------------------------------- | @@ -161,8 +168,8 @@ Standard OpenTelemetry env vars always take precedence over `SetupOtelOptions`: | `OTEL_EXPORTER_OTLP__HEADERS` | Trace-, log-, or metric-specific headers; override generic and code headers. | | `OTEL_METRIC_EXPORT_INTERVAL` | Metric export interval in milliseconds. Defaults to `60000`. | | `OTEL_METRIC_EXPORT_TIMEOUT` | Metric export timeout in milliseconds. Defaults to `30000`. | -| `DEPLOYMENT_ENV` | Attached as `deployment.environment` resource attribute. Defaults to `development`. Also drives the default log level. | -| `LOG_LEVEL` | Minimum log level: `debug` \| `info` \| `warn` \| `error` \| `silent`. Overrides `setLogLevel()` / `setupOtel({ logLevel })`. | +| `DEPLOYMENT_ENV` | Attached as `deployment.environment` resource attribute. Defaults to `development`; does not affect logging. | +| `LOG_LEVEL` | Minimum log level: `debug` \| `info` \| `warn` \| `error` \| `silent`. Used when no programmatic level is set; defaults to `info`. | ## Automatic fetch instrumentation diff --git a/docs/concepts/architecture.mdx b/docs/concepts/architecture.mdx index 3fcedc6..2621df2 100644 --- a/docs/concepts/architecture.mdx +++ b/docs/concepts/architecture.mdx @@ -90,11 +90,13 @@ Local development should not require infrastructure. When no endpoint is configured, the package still installs providers but uses empty processor and reader lists. Application code can call `withSpan()`, `createLogger()`, and `otel.getMeter()` without branching on environment. -This design means the same code path runs locally and in production. The deployment environment decides whether telemetry is exported. +This design means the same code path runs locally and in production. Deployment +configuration decides whether telemetry is exported. -## Why environment variables win +## Why exporter environment variables win -Standard OpenTelemetry environment variables override code-level options. +Standard OpenTelemetry exporter environment variables override code-level +endpoint and header options. This follows OpenTelemetry configuration expectations and keeps deployment concerns outside application code. @@ -102,7 +104,11 @@ For example: - production can inject real exporter credentials through `OTEL_EXPORTER_OTLP_HEADERS` - staging can point to a different collector through `OTEL_EXPORTER_OTLP_ENDPOINT` -- incident response can raise or lower `LOG_LEVEL` without a code deploy +- incident response can raise or lower `LOG_LEVEL` without a code deploy when the host application has not set a programmatic level + +Logger configuration intentionally follows a different SDK-first rule: +`setLogLevel()` or `setupOtel({ logLevel })` wins over `LOG_LEVEL`, and the +unconfigured default is always `info`. ## Logging architecture @@ -112,7 +118,8 @@ This deliberately avoids choosing between local developer ergonomics and structu The log-level gate runs before both sinks. A suppressed debug message does not reach OpenTelemetry and does not reach the console. -The logger resolves the active level on each call so environment or programmatic changes take effect immediately. +The logger resolves the active level on each call so environment changes take +effect immediately until a programmatic override is active. ## Span helper architecture diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 083bb6c..dc29871 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -3,12 +3,14 @@ title: "Configuration" description: "Configure service identity, OTLP endpoints, headers, resource attributes, fetch instrumentation, and log levels." --- -`setupOtel()` accepts code-level defaults, while standard OpenTelemetry environment variables keep final control in deployment environments. +`setupOtel()` accepts application-owned configuration. Standard OpenTelemetry +environment variables keep final control over exporter destinations and +credentials, while an explicit SDK log level remains authoritative. That split is intentional: -- Application code owns stable identity like `serviceName`. -- Deployment configuration owns destinations, credentials, and runtime log level. +- Application code owns stable identity and explicit SDK behavior such as `serviceName` and `logLevel`. +- Deployment configuration owns destinations and credentials, and can supply `LOG_LEVEL` when code leaves the level unset. ## Basic options @@ -142,14 +144,12 @@ Avoid putting per-request values in resource attributes. Use span attributes or If unset, it defaults to `development`. -It also affects the default log level: - -- `debug` when `DEPLOYMENT_ENV` is unset or `development` -- `info` in every other environment +This value only describes telemetry resource metadata. It does not affect the +logger level. ## Log level -You can set a code default: +You can set the application-owned level in code: ```ts setupOtel({ @@ -158,13 +158,17 @@ setupOtel({ }); ``` -Or set `LOG_LEVEL`: +Or use `LOG_LEVEL` when the host application does not set a level: ```bash LOG_LEVEL=warn ``` -`LOG_LEVEL` wins over `setupOtel({ logLevel })` and `setLogLevel()`. +Resolution order is: + +1. `setLogLevel()` or `setupOtel({ logLevel })` +2. a valid `LOG_LEVEL` +3. `info` Allowed values are: @@ -176,6 +180,11 @@ Allowed values are: `silent` suppresses all logs, including errors. +Environment values are trimmed and case-insensitive; an empty value is treated +as unset. An invalid `LOG_LEVEL` warns once per distinct normalized value and +falls back to `info`. Invalid programmatic values from untyped JavaScript callers +throw a `TypeError` before changing logger or setup state. + ## Fetch instrumentation By default, `setupOtel()` instruments `fetch` when a traces endpoint is configured. @@ -248,7 +257,7 @@ The package always excludes its own OTLP exporter endpoints from fetch instrumen | Log endpoint | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | base endpoint + `/v1/logs` | | Metric endpoint | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | base endpoint + `/v1/metrics` | | Signal headers | signal-specific `*_HEADERS` | generic headers, then `setupOtel({ headers })` | -| Log level | `LOG_LEVEL` | `setupOtel({ logLevel })` or `setLogLevel()` | +| Log level | `setupOtel({ logLevel })` or `setLogLevel()` | `LOG_LEVEL`, then `info` | | Deployment environment | `DEPLOYMENT_ENV` | `development` | ## Best practices @@ -257,5 +266,5 @@ The package always excludes its own OTLP exporter endpoints from fetch instrumen - Always set `serviceName`. - Set `serviceVersion` from your release artifact when possible. - Prefer stable resource attributes over high-cardinality request values. -- Use `LOG_LEVEL=debug` temporarily when debugging production incidents, then return to `info` or higher. +- Use `LOG_LEVEL=debug` temporarily only when the application has not set a programmatic level; otherwise change the explicit SDK configuration. - Avoid putting secrets in URL query strings (fetch spans include `url.full`); when unavoidable, strip them with the `redactUrl` option and the `sanitizeUrl()` helper. diff --git a/docs/guides/logging.mdx b/docs/guides/logging.mdx index 70210a2..7e887fe 100644 --- a/docs/guides/logging.mdx +++ b/docs/guides/logging.mdx @@ -97,21 +97,25 @@ The active level is resolved fresh on every log call. Resolution order: -1. `LOG_LEVEL` -2. `setLogLevel()` or `setupOtel({ logLevel })` -3. default level from `DEPLOYMENT_ENV` +1. `setLogLevel()` or `setupOtel({ logLevel })` +2. a valid `LOG_LEVEL` +3. `info` ```ts import { getLogLevel, setLogLevel } from "@photon-ai/otel"; setLogLevel("warn"); -console.log(getLogLevel()); // "warn", unless LOG_LEVEL is set +console.log(getLogLevel()); // "warn", even if LOG_LEVEL is set ``` -Defaults: +`DEPLOYMENT_ENV` only supplies the `deployment.environment` telemetry resource +attribute. It never changes the logger level. -- `debug` when `DEPLOYMENT_ENV` is unset or `development` -- `info` otherwise +Environment values are trimmed and case-insensitive. Empty values behave as +unset. An invalid active `LOG_LEVEL` warns once per distinct normalized value +and falls back to `info`; it is not inspected when a programmatic level is +active. Invalid programmatic values from untyped JavaScript callers throw a +`TypeError` immediately. Allowed values: @@ -187,5 +191,5 @@ In a backend that supports trace/log correlation, that log can be opened from th - Use stable module names. - Attach attributes for business identifiers and operational context. - Pass exceptions as the third argument instead of stringifying them. -- Set `LOG_LEVEL` in deployment configuration. +- Prefer a programmatic level when the host application owns logging policy; use `LOG_LEVEL` as the deployment fallback otherwise. - Avoid logging raw PII, secrets, or full request bodies. diff --git a/docs/reference/api.mdx b/docs/reference/api.mdx index 0b14506..727eb1e 100644 --- a/docs/reference/api.mdx +++ b/docs/reference/api.mdx @@ -52,6 +52,7 @@ interface SetupOtelOptions { - Installs W3C trace-context and baggage propagation. - Configures OTLP/HTTP trace, log, and metric exporters when endpoints are available. - Supports no-endpoint local development. +- Applies `logLevel` as a programmatic override above `LOG_LEVEL`; defaults to `info` when neither is configured. - Optionally instruments fetch. - Returns an `OtelHandle`. @@ -141,7 +142,8 @@ Sets the programmatic minimum log level. function setLogLevel(level: LogLevel): void; ``` -`LOG_LEVEL` still wins when it is set. +This setting takes precedence over `LOG_LEVEL`. Untyped JavaScript callers that +pass an invalid value receive a `TypeError` before the active level changes. ## `getLogLevel()` @@ -151,7 +153,9 @@ Returns the current effective log level. function getLogLevel(): LogLevel; ``` -The value is resolved from `LOG_LEVEL`, then programmatic override, then deployment default. +The value is resolved from programmatic configuration, then a valid +`LOG_LEVEL`, then the unconditional `info` default. `DEPLOYMENT_ENV` does not +participate in logger resolution. ## `LogLevel` diff --git a/docs/reference/environment.mdx b/docs/reference/environment.mdx index 07e240c..5e5ad43 100644 --- a/docs/reference/environment.mdx +++ b/docs/reference/environment.mdx @@ -1,15 +1,17 @@ --- title: "Environment reference" -description: "Environment variables recognized by @photon-ai/otel and how they override code options." +description: "Environment variables recognized by @photon-ai/otel and their precedence and fallback behavior." --- This package follows standard OpenTelemetry environment variable precedence for OTLP exporter configuration. -Environment variables are deployment-owned configuration. They override code defaults where applicable. +Environment variables are deployment-owned configuration. Standard +OpenTelemetry exporter variables override code defaults where applicable; +`LOG_LEVEL` is used only when no programmatic logger level is active. ## Summary -| Variable | Purpose | Overrides | +| Variable | Purpose | Precedence or fallback | | --- | --- | --- | | `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP/HTTP endpoint | `setupOtel({ endpoint })` | | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Full traces endpoint | base endpoint for traces | @@ -19,8 +21,8 @@ Environment variables are deployment-owned configuration. They override code def | `OTEL_EXPORTER_OTLP__HEADERS` | Signal-specific exporter headers | generic and code headers | | `OTEL_METRIC_EXPORT_INTERVAL` | Metric export interval in milliseconds | default `60000` | | `OTEL_METRIC_EXPORT_TIMEOUT` | Metric export timeout in milliseconds | default `30000` | -| `DEPLOYMENT_ENV` | deployment resource attribute and default log-level input | default `development` | -| `LOG_LEVEL` | logger minimum severity | `setupOtel({ logLevel })` and `setLogLevel()` | +| `DEPLOYMENT_ENV` | deployment resource attribute | default `development` | +| `LOG_LEVEL` | logger minimum severity | programmatic level wins; otherwise default `info` | ## `OTEL_EXPORTER_OTLP_ENDPOINT` @@ -118,10 +120,7 @@ The value is attached to telemetry as `deployment.environment`. If unset, the package uses `development`. -It also controls the default log level: - -- `development` or unset: `debug` -- any other value: `info` +It does not participate in logger-level resolution. ## `LOG_LEVEL` @@ -139,9 +138,13 @@ Allowed values: - `error` - `silent` -Invalid values are ignored. +Values are trimmed and case-insensitive. Empty values are treated as unset. +Invalid values warn once per distinct normalized value and fall back to `info`. -`LOG_LEVEL` is checked on every log call and wins over both `setupOtel({ logLevel })` and `setLogLevel()`. +When no programmatic level is active, `LOG_LEVEL` is checked on every log call +so deployment changes take effect immediately. Both `setupOtel({ logLevel })` +and `setLogLevel()` take precedence over it. If neither source supplies a valid +level, the logger defaults to `info`. ## Local development example diff --git a/src/logger.ts b/src/logger.ts index ead8be2..21c318f 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -11,6 +11,8 @@ export type LogAttrs = Record; */ export type LogLevel = "debug" | "info" | "warn" | "error" | "silent"; +const LOG_LEVELS = ["debug", "info", "warn", "error", "silent"] as const; +const DEFAULT_LOG_LEVEL: LogLevel = "info"; const LEVEL_SEVERITY: Record = { debug: SeverityNumber.DEBUG, // 5 info: SeverityNumber.INFO, // 9 @@ -20,42 +22,62 @@ const LEVEL_SEVERITY: Record = { }; let levelOverride: LogLevel | undefined; +const warnedInvalidEnvLevels = new Set(); + +function isLogLevel(value: unknown): value is LogLevel { + return LOG_LEVELS.some((level) => level === value); +} function envLevel(): LogLevel | undefined { - const raw = process.env.LOG_LEVEL?.toLowerCase(); - if (raw && raw in LEVEL_SEVERITY) { - return raw as LogLevel; + const raw = process.env.LOG_LEVEL; + if (raw === undefined) { + return; + } + + const normalized = raw.trim().toLowerCase(); + if (!normalized) { + return; + } + if (isLogLevel(normalized)) { + return normalized; } - return; -} -function defaultLevel(): LogLevel { - return (process.env.DEPLOYMENT_ENV ?? "development") === "development" - ? "debug" - : "info"; + if (!warnedInvalidEnvLevels.has(normalized)) { + warnedInvalidEnvLevels.add(normalized); + console.warn( + `[@photon-ai/otel] Ignoring invalid LOG_LEVEL ${JSON.stringify(raw)}; expected one of: ${LOG_LEVELS.join(", ")}. Using ${DEFAULT_LOG_LEVEL}.` + ); + } + return; } /** * Resolve the active level fresh on each call so that `LOG_LEVEL` changes and - * `setLogLevel()` both take effect immediately. Resolution order (env wins, to - * match the rest of the package's config story): - * 1. `LOG_LEVEL` env var - * 2. `setLogLevel()` / `setupOtel({ logLevel })` - * 3. environment-driven default (`debug` in development, `info` otherwise) + * `setLogLevel()` both take effect immediately. Resolution order: + * 1. `setLogLevel()` / `setupOtel({ logLevel })` + * 2. `LOG_LEVEL` env var + * 3. `info` */ function resolveLevel(): LogLevel { - return envLevel() ?? levelOverride ?? defaultLevel(); + return levelOverride ?? envLevel() ?? DEFAULT_LOG_LEVEL; } /** * Programmatically set the minimum log level. Takes effect immediately for - * subsequent logs. `LOG_LEVEL` env var still wins if set. + * subsequent logs and takes precedence over `LOG_LEVEL`. + * + * Invalid runtime values from untyped JavaScript callers throw a `TypeError`. */ export function setLogLevel(level: LogLevel): void { + if (!isLogLevel(level)) { + throw new TypeError( + `Invalid log level; expected one of: ${LOG_LEVELS.join(", ")}.` + ); + } levelOverride = level; } -/** Current effective log level, after env / override / default resolution. */ +/** Current effective log level after programmatic / env / default resolution. */ export function getLogLevel(): LogLevel { return resolveLevel(); } diff --git a/src/setup.ts b/src/setup.ts index e2d150f..ac361bd 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -75,8 +75,10 @@ export interface SetupOtelOptions { instrumentFetch?: boolean | InstrumentFetchOptions; /** * Minimum log level emitted by `createLogger()` (to both OTLP and console). - * The `LOG_LEVEL` env var still takes precedence. Defaults to `debug` in - * development and `info` otherwise. + * Takes precedence over `LOG_LEVEL`. Defaults to `info`, independently of + * `DEPLOYMENT_ENV`. + * + * Invalid runtime values from untyped JavaScript callers throw a `TypeError`. */ logLevel?: LogLevel; /** @@ -234,7 +236,7 @@ export function setupOtel(options: SetupOtelOptions): OtelHandle { const register = options.register !== false; - if (options.logLevel) { + if (options.logLevel !== undefined) { setLogLevel(options.logLevel); } diff --git a/tests/integration/otel-collector.test.ts b/tests/integration/otel-collector.test.ts index 36cc16a..cd83c6c 100644 --- a/tests/integration/otel-collector.test.ts +++ b/tests/integration/otel-collector.test.ts @@ -306,9 +306,10 @@ let errorSpanRejected = false; beforeAll(async () => { process.env.OTEL_EXPORTER_OTLP_ENDPOINT ??= "http://localhost:4318"; - // Keep level resolution deterministic regardless of the CI environment - // (LOG_LEVEL would otherwise win over the logLevel option below). - delete process.env.LOG_LEVEL; + // Deliberately conflict with the programmatic level below: SDK configuration + // must win while deployment metadata keeps its independent default. + process.env.LOG_LEVEL = "silent"; + delete process.env.DEPLOYMENT_ENV; const handle = setupOtel({ serviceName: SERVICE_NAME, @@ -398,7 +399,7 @@ describe("real OTLP/HTTP round-trip to an OpenTelemetry Collector", () => { expect(span?.resource["service.name"]).toBe(SERVICE_NAME); expect(span?.resource["service.version"]).toBe(PHOTON_OTEL_VERSION); expect(span?.resource["test.nonce"]).toBe(nonce); - expect(span?.resource["deployment.environment"]).toBeDefined(); + expect(span?.resource["deployment.environment"]).toBe("development"); }); it("delivers the error span with ERROR status and a PII-scrubbed message", () => { diff --git a/tests/logger.test.ts b/tests/logger.test.ts index c663ce1..fc2a537 100644 --- a/tests/logger.test.ts +++ b/tests/logger.test.ts @@ -167,46 +167,105 @@ describe("log level gating", () => { expect(console.error).not.toHaveBeenCalled(); }); - it("lets LOG_LEVEL env win over setLogLevel()", () => { + it("lets setLogLevel() win over LOG_LEVEL", () => { setLogLevel("debug"); process.env.LOG_LEVEL = "error"; const log = createLogger("svc"); + log.debug("d"); log.info("i"); log.error("e"); - expect(getLogLevel()).toBe("error"); + expect(getLogLevel()).toBe("debug"); expect(exporter.getFinishedLogRecords().map((r) => r.severityText)).toEqual( - ["ERROR"] + ["DEBUG", "INFO", "ERROR"] ); }); }); describe("getLogLevel resolution", () => { afterEach(() => { + clearEnv(); vi.restoreAllMocks(); }); - it("env-driven default is debug in development", async () => { + it.each([ + { deploymentEnv: undefined, name: "unset" }, + { deploymentEnv: "development", name: "development" }, + { deploymentEnv: "staging", name: "staging" }, + { deploymentEnv: "production", name: "production" }, + ])("defaults to info when DEPLOYMENT_ENV is $name", async ({ + deploymentEnv, + }) => { vi.resetModules(); delete process.env.LOG_LEVEL; - process.env.DEPLOYMENT_ENV = "development"; + if (deploymentEnv === undefined) { + delete process.env.DEPLOYMENT_ENV; + } else { + process.env.DEPLOYMENT_ENV = deploymentEnv; + } + + const fresh = await import("../src/logger"); + expect(fresh.getLogLevel()).toBe("info"); + }); + + it("trims and lowercases a valid LOG_LEVEL", async () => { + vi.resetModules(); + process.env.LOG_LEVEL = " DEBUG "; + const fresh = await import("../src/logger"); expect(fresh.getLogLevel()).toBe("debug"); }); - it("env-driven default is info outside development", async () => { + it("treats an empty LOG_LEVEL as unset without warning", async () => { vi.resetModules(); - delete process.env.LOG_LEVEL; - process.env.DEPLOYMENT_ENV = "production"; + process.env.LOG_LEVEL = " "; + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const fresh = await import("../src/logger"); expect(fresh.getLogLevel()).toBe("info"); + expect(warn).not.toHaveBeenCalled(); }); - it("ignores an invalid LOG_LEVEL value", async () => { + it("warns once per distinct invalid normalized LOG_LEVEL", async () => { vi.resetModules(); - process.env.LOG_LEVEL = "loud"; - process.env.DEPLOYMENT_ENV = "production"; + exporter.reset(); + process.env.LOG_LEVEL = " LOUD "; + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const fresh = await import("../src/logger"); expect(fresh.getLogLevel()).toBe("info"); + expect(fresh.getLogLevel()).toBe("info"); + process.env.LOG_LEVEL = "loud"; + expect(fresh.getLogLevel()).toBe("info"); + expect(warn).toHaveBeenCalledTimes(1); + + process.env.LOG_LEVEL = "verbose"; + expect(fresh.getLogLevel()).toBe("info"); + expect(warn).toHaveBeenCalledTimes(2); + expect(exporter.getFinishedLogRecords()).toHaveLength(0); + }); + + it("does not inspect invalid LOG_LEVEL when an override is active", async () => { + vi.resetModules(); + process.env.LOG_LEVEL = "verbose"; + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const fresh = await import("../src/logger"); + fresh.setLogLevel("warn"); + expect(fresh.getLogLevel()).toBe("warn"); + expect(warn).not.toHaveBeenCalled(); + }); + + it("rejects invalid JavaScript values without changing the override", async () => { + vi.resetModules(); + const fresh = await import("../src/logger"); + fresh.setLogLevel("warn"); + + expect(() => + Reflect.apply(fresh.setLogLevel, undefined, ["verbose"]) + ).toThrowError( + "Invalid log level; expected one of: debug, info, warn, error, silent." + ); + expect(fresh.getLogLevel()).toBe("warn"); }); }); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index fcb4bd3..749f9c4 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -2,6 +2,7 @@ import dc from "node:diagnostics_channel"; import { metrics } from "@opentelemetry/api"; import { MeterProvider as SdkMeterProvider } from "@opentelemetry/sdk-metrics"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getLogLevel, setLogLevel } from "../src/logger"; import { isOtelActive, setupOtel } from "../src/setup"; const ENV_KEYS = [ @@ -16,6 +17,7 @@ const ENV_KEYS = [ "OTEL_METRIC_EXPORT_INTERVAL", "OTEL_METRIC_EXPORT_TIMEOUT", "DEPLOYMENT_ENV", + "LOG_LEVEL", ] as const; // Fetch instrumentation has two strategies and only one touches @@ -79,6 +81,20 @@ describe("setupOtel", () => { expect(typeof handle.shutdown).toBe("function"); }); + it("rejects an invalid JavaScript logLevel before changing state", () => { + setLogLevel("warn"); + + expect(() => + Reflect.apply(setupOtel, undefined, [ + { serviceName: "invalid-level", logLevel: "verbose" }, + ]) + ).toThrowError( + "Invalid log level; expected one of: debug, info, warn, error, silent." + ); + expect(isOtelActive()).toBe(false); + expect(getLogLevel()).toBe("warn"); + }); + it("getMeter delegates to this setup's provider", () => { const handle = setupOtel({ serviceName: "meter-handle" });