From ff06c8a402e32258ffb0484fc6bad9c813b73935 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Mon, 6 Oct 2025 16:28:44 -0700 Subject: [PATCH 1/5] Add loggerConfigurator and tests. --- experimental/packages/sdk-logs/src/Logger.ts | 40 +- .../packages/sdk-logs/src/LoggerProvider.ts | 3 +- .../src/config/LoggerConfigurators.ts | 116 ++++++ experimental/packages/sdk-logs/src/index.ts | 6 + .../src/internal/LoggerProviderSharedState.ts | 55 ++- experimental/packages/sdk-logs/src/types.ts | 51 +++ .../sdk-logs/test/common/Logger.test.ts | 354 +++++++++++++++++- 7 files changed, 619 insertions(+), 6 deletions(-) create mode 100644 experimental/packages/sdk-logs/src/config/LoggerConfigurators.ts diff --git a/experimental/packages/sdk-logs/src/Logger.ts b/experimental/packages/sdk-logs/src/Logger.ts index e7d42dbf733..d4ee1aea0f4 100644 --- a/experimental/packages/sdk-logs/src/Logger.ts +++ b/experimental/packages/sdk-logs/src/Logger.ts @@ -15,8 +15,9 @@ */ import type * as logsAPI from '@opentelemetry/api-logs'; +import { SeverityNumber } from '@opentelemetry/api-logs'; import type { InstrumentationScope } from '@opentelemetry/core'; -import { context } from '@opentelemetry/api'; +import { context, trace, TraceFlags, isSpanContextValid } from '@opentelemetry/api'; import { LogRecordImpl } from './LogRecordImpl'; import { LoggerProviderSharedState } from './internal/LoggerProviderSharedState'; @@ -28,7 +29,44 @@ export class Logger implements logsAPI.Logger { ) {} public emit(logRecord: logsAPI.LogRecord): void { + // Get logger configuration + const loggerConfig = this._sharedState.getLoggerConfig( + this.instrumentationScope + ); + const currentContext = logRecord.context || context.active(); + + // Apply minimum severity filtering + const recordSeverity = + logRecord.severityNumber ?? SeverityNumber.UNSPECIFIED; + + // 1. Minimum severity: If the log record's SeverityNumber is specified + // (i.e. not 0) and is less than the configured minimum_severity, + // the log record MUST be dropped. + if ( + recordSeverity !== SeverityNumber.UNSPECIFIED && + recordSeverity < loggerConfig.minimumSeverity + ) { + // Log record is dropped due to minimum severity filter + return; + } + + // 2. Trace-based: If trace_based is true, and if the log record has a + // SpanId and the TraceFlags SAMPLED flag is unset, the log record MUST be dropped. + if (loggerConfig.traceBased) { + const spanContext = trace.getSpanContext(currentContext); + if (spanContext && isSpanContextValid(spanContext)) { + // Check if the trace is unsampled (SAMPLED flag is unset) + const isSampled = (spanContext.traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED; + if (!isSampled) { + // Log record is dropped due to trace-based filter + return; + } + } + // If there's no valid span context, the log record is not associated with a trace + // and therefore bypasses trace-based filtering (as per spec) + } + /** * If a Logger was obtained with include_trace_context=true, * the LogRecords it emits MUST automatically include the Trace Context from the active Context, diff --git a/experimental/packages/sdk-logs/src/LoggerProvider.ts b/experimental/packages/sdk-logs/src/LoggerProvider.ts index 517e2a102ae..a58bf06b1d5 100644 --- a/experimental/packages/sdk-logs/src/LoggerProvider.ts +++ b/experimental/packages/sdk-logs/src/LoggerProvider.ts @@ -37,7 +37,8 @@ export class LoggerProvider implements logsAPI.LoggerProvider { resource, mergedConfig.forceFlushTimeoutMillis, reconfigureLimits(mergedConfig.logRecordLimits), - config?.processors ?? [] + config?.processors ?? [], + config?.loggerConfigurator ); this._shutdownOnce = new BindOnceFuture(this._shutdown, this); } diff --git a/experimental/packages/sdk-logs/src/config/LoggerConfigurators.ts b/experimental/packages/sdk-logs/src/config/LoggerConfigurators.ts new file mode 100644 index 00000000000..c84aa2e9b1f --- /dev/null +++ b/experimental/packages/sdk-logs/src/config/LoggerConfigurators.ts @@ -0,0 +1,116 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { InstrumentationScope } from '@opentelemetry/core'; +import { SeverityNumber } from '@opentelemetry/api-logs'; +import type { LoggerConfig, LoggerConfigurator } from '../types'; + +/** + * Default LoggerConfig used when no pattern matches + */ +const DEFAULT_LOGGER_CONFIG: Required = { + disabled: false, + minimumSeverity: SeverityNumber.UNSPECIFIED, + traceBased: false, +}; + +/** + * Configuration for a specific logger pattern + */ +export interface LoggerPattern { + /** + * The logger name or pattern to match. + * Use '*' for wildcard matching. + */ + pattern: string; + + /** + * The configuration to apply to matching loggers. + * Partial config is allowed; unspecified properties will use defaults. + */ + config: LoggerConfig; +} + +/** + * Creates a LoggerConfigurator from an array of logger patterns. + * Patterns are evaluated in order, and the first matching pattern's config is used. + * Supports exact matching and simple wildcard patterns with '*'. + * + * The returned configurator computes a complete LoggerConfig by merging the matched + * pattern's config with default values for any unspecified properties. + * + * @param patterns - Array of logger patterns with their configurations + * @returns A LoggerConfigurator function that computes complete LoggerConfig + * + * @example + * ```typescript + * const configurator = createLoggerConfigurator([ + * { pattern: 'debug-logger', config: { minimumSeverity: SeverityNumber.DEBUG } }, + * { pattern: 'prod-*', config: { minimumSeverity: SeverityNumber.WARN } }, + * { pattern: '*', config: { minimumSeverity: SeverityNumber.INFO } }, + * ]); + * ``` + */ +export function createLoggerConfigurator( + patterns: LoggerPattern[] +): LoggerConfigurator { + return (loggerScope: InstrumentationScope): Required => { + const loggerName = loggerScope.name; + + // Find the first matching pattern + for (const { pattern, config } of patterns) { + if (matchesPattern(loggerName, pattern)) { + // Compute complete config by merging with defaults + return { + disabled: config.disabled ?? DEFAULT_LOGGER_CONFIG.disabled, + minimumSeverity: + config.minimumSeverity ?? DEFAULT_LOGGER_CONFIG.minimumSeverity, + traceBased: config.traceBased ?? DEFAULT_LOGGER_CONFIG.traceBased, + }; + } + } + + // No pattern matched, return default config + return { ...DEFAULT_LOGGER_CONFIG }; + }; +} + +/** + * Matches a logger name against a pattern. + * Supports simple wildcard matching with '*'. + * + * @param name - The logger name to match + * @param pattern - The pattern to match against (supports '*' wildcard) + * @returns true if the name matches the pattern + */ +function matchesPattern(name: string, pattern: string): boolean { + // Exact match + if (pattern === name) { + return true; + } + + // Wildcard pattern + if (pattern.includes('*')) { + const regexPattern = pattern + .split('*') + .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('.*'); + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(name); + } + + return false; +} diff --git a/experimental/packages/sdk-logs/src/index.ts b/experimental/packages/sdk-logs/src/index.ts index 6b7a4a03fbf..4b961ba6711 100644 --- a/experimental/packages/sdk-logs/src/index.ts +++ b/experimental/packages/sdk-logs/src/index.ts @@ -16,6 +16,8 @@ export type { LoggerProviderConfig, + LoggerConfig, + LoggerConfigurator, LogRecordLimits, BufferConfig, BatchLogRecordProcessorBrowserConfig, @@ -30,3 +32,7 @@ export type { LogRecordExporter } from './export/LogRecordExporter'; export { SimpleLogRecordProcessor } from './export/SimpleLogRecordProcessor'; export { InMemoryLogRecordExporter } from './export/InMemoryLogRecordExporter'; export { BatchLogRecordProcessor } from './platform'; +export { + createLoggerConfigurator, + type LoggerPattern, +} from './config/LoggerConfigurators'; diff --git a/experimental/packages/sdk-logs/src/internal/LoggerProviderSharedState.ts b/experimental/packages/sdk-logs/src/internal/LoggerProviderSharedState.ts index 7580e300827..60edb776427 100644 --- a/experimental/packages/sdk-logs/src/internal/LoggerProviderSharedState.ts +++ b/experimental/packages/sdk-logs/src/internal/LoggerProviderSharedState.ts @@ -15,22 +15,40 @@ */ import { Logger } from '@opentelemetry/api-logs'; +import { SeverityNumber } from '@opentelemetry/api-logs'; import { Resource } from '@opentelemetry/resources'; +import type { InstrumentationScope } from '@opentelemetry/core'; import { LogRecordProcessor } from '../LogRecordProcessor'; -import { LogRecordLimits } from '../types'; +import { LogRecordLimits, LoggerConfig, LoggerConfigurator } from '../types'; import { NoopLogRecordProcessor } from '../export/NoopLogRecordProcessor'; import { MultiLogRecordProcessor } from '../MultiLogRecordProcessor'; +const DEFAULT_LOGGER_CONFIG: Required = { + disabled: false, + minimumSeverity: SeverityNumber.UNSPECIFIED, + traceBased: false, +}; + +/** + * Default LoggerConfigurator that returns the default config for all loggers + */ +const DEFAULT_LOGGER_CONFIGURATOR: LoggerConfigurator = () => ({ + ...DEFAULT_LOGGER_CONFIG, +}); + export class LoggerProviderSharedState { readonly loggers: Map = new Map(); activeProcessor: LogRecordProcessor; readonly registeredLogRecordProcessors: LogRecordProcessor[] = []; + private _loggerConfigurator: LoggerConfigurator; + private _loggerConfigs: Map> = new Map(); constructor( readonly resource: Resource, readonly forceFlushTimeoutMillis: number, readonly logRecordLimits: Required, - readonly processors: LogRecordProcessor[] + readonly processors: LogRecordProcessor[], + loggerConfigurator?: LoggerConfigurator ) { if (processors.length > 0) { this.registeredLogRecordProcessors = processors; @@ -41,5 +59,38 @@ export class LoggerProviderSharedState { } else { this.activeProcessor = new NoopLogRecordProcessor(); } + + this._loggerConfigurator = + loggerConfigurator ?? DEFAULT_LOGGER_CONFIGURATOR; + } + + /** + * Get the LoggerConfig for a given instrumentation scope. + * Uses the LoggerConfigurator function to compute the config on first access + * and caches the result. + */ + getLoggerConfig( + instrumentationScope: InstrumentationScope + ): Required { + const key = this._getScopeKey(instrumentationScope); + + // Return cached config if available + let config = this._loggerConfigs.get(key); + if (config) { + return config; + } + + // Compute config using the configurator + // The configurator always returns a complete config + config = this._loggerConfigurator(instrumentationScope); + + // Cache the result + this._loggerConfigs.set(key, config); + + return config; + } + + private _getScopeKey(scope: InstrumentationScope): string { + return `${scope.name}@${scope.version || ''}:${scope.schemaUrl || ''}`; } } diff --git a/experimental/packages/sdk-logs/src/types.ts b/experimental/packages/sdk-logs/src/types.ts index c705db32007..999ccc6828f 100644 --- a/experimental/packages/sdk-logs/src/types.ts +++ b/experimental/packages/sdk-logs/src/types.ts @@ -15,8 +15,53 @@ */ import type { Resource } from '@opentelemetry/resources'; +import type { SeverityNumber } from '@opentelemetry/api-logs'; +import type { InstrumentationScope } from '@opentelemetry/core'; import { LogRecordProcessor } from './LogRecordProcessor'; +/** + * A LoggerConfig defines various configurable aspects of a Logger's behavior. + */ +export interface LoggerConfig { + /** + * A boolean indication of whether the logger is enabled. + * If a Logger is disabled, it behaves equivalently to a No-op Logger. + * Defaults to false (loggers are enabled by default). + */ + disabled?: boolean; + + /** + * A SeverityNumber indicating the minimum severity level for log records to be processed. + * If not explicitly set, defaults to 0 (UNSPECIFIED). + * Log records with a specified severity (i.e. not 0) that is less than this value will be dropped. + * Log records with unspecified severity (0) bypass this filter. + */ + minimumSeverity?: SeverityNumber; + + /** + * A boolean indication of whether the logger should only process log records + * associated with sampled traces. + * If not explicitly set, defaults to false. + * If true, log records associated with unsampled traces will be dropped. + */ + traceBased?: boolean; +} + +/** + * A LoggerConfigurator is a function which computes the LoggerConfig for a Logger. + * It is called when a Logger is first created, and for each outstanding Logger + * when a LoggerProvider's LoggerConfigurator is updated (if updating is supported). + * + * The function must return the complete LoggerConfig for the given logger scope. + * All config properties should have their values computed and set to appropriate defaults. + * + * @param loggerScope - The InstrumentationScope of the Logger + * @returns The computed LoggerConfig with all properties set + */ +export type LoggerConfigurator = ( + loggerScope: InstrumentationScope +) => Required; + export interface LoggerProviderConfig { /** Resource associated with trace telemetry */ resource?: Resource; @@ -32,6 +77,12 @@ export interface LoggerProviderConfig { /** Log Record Processors */ processors?: LogRecordProcessor[]; + + /** + * A function that computes the LoggerConfig for a given logger. + * This is called when a Logger is first created. + */ + loggerConfigurator?: LoggerConfigurator; } export interface LogRecordLimits { diff --git a/experimental/packages/sdk-logs/test/common/Logger.test.ts b/experimental/packages/sdk-logs/test/common/Logger.test.ts index 013d92ffa7e..76c14277c7d 100644 --- a/experimental/packages/sdk-logs/test/common/Logger.test.ts +++ b/experimental/packages/sdk-logs/test/common/Logger.test.ts @@ -17,11 +17,20 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; -import { LoggerProvider, NoopLogRecordProcessor } from '../../src'; +import { + LoggerProvider, + NoopLogRecordProcessor, + createLoggerConfigurator, +} from '../../src'; import { LogRecordImpl } from '../../src/LogRecordImpl'; import { ROOT_CONTEXT, TraceFlags, context, trace } from '@opentelemetry/api'; -import { LogRecord as ApiLogRecord } from '@opentelemetry/api-logs'; +import { + LogRecord as ApiLogRecord, + SeverityNumber, +} from '@opentelemetry/api-logs'; import { Logger } from '../../src/Logger'; +import { InMemoryLogRecordExporter } from '../../src/export/InMemoryLogRecordExporter'; +import { SimpleLogRecordProcessor } from '../../src/export/SimpleLogRecordProcessor'; const setup = () => { const logProcessor = new NoopLogRecordProcessor(); @@ -84,5 +93,346 @@ describe('Logger', () => { logger.emit(logRecordData); assert.ok(callSpy.calledWith(sinon.match.any, activeContext)); }); + + describe('minimum severity filtering', () => { + it('should emit log records with severity above minimum', () => { + const exporter = new InMemoryLogRecordExporter(); + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(exporter)], + loggerConfigurator: createLoggerConfigurator([ + { + pattern: 'test-logger', + config: { minimumSeverity: SeverityNumber.INFO }, + }, + ]), + }); + const logger = loggerProvider.getLogger('test-logger') as Logger; + + logger.emit({ + body: 'info message', + severityNumber: SeverityNumber.INFO, + }); + + logger.emit({ + body: 'warn message', + severityNumber: SeverityNumber.WARN, + }); + + const logRecords = exporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 2); + }); + + it('should drop log records with severity below minimum', () => { + const exporter = new InMemoryLogRecordExporter(); + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(exporter)], + loggerConfigurator: createLoggerConfigurator([ + { + pattern: 'test-logger', + config: { minimumSeverity: SeverityNumber.WARN }, + }, + ]), + }); + const logger = loggerProvider.getLogger('test-logger') as Logger; + + logger.emit({ + body: 'debug message', + severityNumber: SeverityNumber.DEBUG, + }); + + logger.emit({ + body: 'info message', + severityNumber: SeverityNumber.INFO, + }); + + const logRecords = exporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 0); + }); + + it('should emit log records with severity equal to minimum', () => { + const exporter = new InMemoryLogRecordExporter(); + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(exporter)], + loggerConfigurator: createLoggerConfigurator([ + { + pattern: 'test-logger', + config: { minimumSeverity: SeverityNumber.WARN }, + }, + ]), + }); + const logger = loggerProvider.getLogger('test-logger') as Logger; + + logger.emit({ + body: 'warn message', + severityNumber: SeverityNumber.WARN, + }); + + const logRecords = exporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + }); + + it('should emit log records with unspecified severity (0) regardless of minimum', () => { + const exporter = new InMemoryLogRecordExporter(); + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(exporter)], + loggerConfigurator: createLoggerConfigurator([ + { + pattern: 'test-logger', + config: { minimumSeverity: SeverityNumber.ERROR }, + }, + ]), + }); + const logger = loggerProvider.getLogger('test-logger') as Logger; + + logger.emit({ + body: 'unspecified severity message', + severityNumber: SeverityNumber.UNSPECIFIED, + }); + + logger.emit({ + body: 'no severity specified', + // severityNumber not specified at all + }); + + const logRecords = exporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 2); + }); + + it('should use default minimum severity of 0 when not configured', () => { + const exporter = new InMemoryLogRecordExporter(); + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(exporter)], + }); + const logger = loggerProvider.getLogger( + 'unconfigured-logger' + ) as Logger; + + logger.emit({ + body: 'debug message', + severityNumber: SeverityNumber.DEBUG, + }); + + logger.emit({ + body: 'trace message', + severityNumber: SeverityNumber.TRACE, + }); + + const logRecords = exporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 2); + }); + + it('should only filter logs for configured logger, not other loggers', () => { + const exporter = new InMemoryLogRecordExporter(); + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(exporter)], + loggerConfigurator: createLoggerConfigurator([ + { + pattern: 'filtered-logger', + config: { minimumSeverity: SeverityNumber.ERROR }, + }, + { + pattern: '*', + config: { minimumSeverity: SeverityNumber.UNSPECIFIED }, + }, + ]), + }); + const filteredLogger = loggerProvider.getLogger( + 'filtered-logger' + ) as Logger; + const unfilteredLogger = loggerProvider.getLogger( + 'unfiltered-logger' + ) as Logger; + + // Should be dropped (below minimum) + filteredLogger.emit({ + body: 'filtered debug', + severityNumber: SeverityNumber.DEBUG, + }); + + // Should be emitted (no filter configured) + unfilteredLogger.emit({ + body: 'unfiltered debug', + severityNumber: SeverityNumber.DEBUG, + }); + + const logRecords = exporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual(logRecords[0].body, 'unfiltered debug'); + }); + }); + + describe('trace-based filtering', () => { + it('should emit log records associated with sampled traces', () => { + const exporter = new InMemoryLogRecordExporter(); + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(exporter)], + loggerConfigurator: createLoggerConfigurator([ + { + pattern: 'test-logger', + config: { traceBased: true }, + }, + ]), + }); + const logger = loggerProvider.getLogger('test-logger') as Logger; + + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + const activeContext = trace.setSpanContext(ROOT_CONTEXT, spanContext); + + logger.emit({ + body: 'sampled trace message', + context: activeContext, + }); + + const logRecords = exporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + }); + + it('should drop log records associated with unsampled traces', () => { + const exporter = new InMemoryLogRecordExporter(); + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(exporter)], + loggerConfigurator: createLoggerConfigurator([ + { + pattern: 'test-logger', + config: { traceBased: true }, + }, + ]), + }); + const logger = loggerProvider.getLogger('test-logger') as Logger; + + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + }; + const activeContext = trace.setSpanContext(ROOT_CONTEXT, spanContext); + + logger.emit({ + body: 'unsampled trace message', + context: activeContext, + }); + + const logRecords = exporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 0); + }); + + it('should emit log records without trace context when trace-based filtering is enabled', () => { + const exporter = new InMemoryLogRecordExporter(); + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(exporter)], + loggerConfigurator: createLoggerConfigurator([ + { + pattern: 'test-logger', + config: { traceBased: true }, + }, + ]), + }); + const logger = loggerProvider.getLogger('test-logger') as Logger; + + logger.emit({ + body: 'message without trace context', + context: ROOT_CONTEXT, + }); + + const logRecords = exporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + }); + + it('should not filter when trace-based filtering is disabled (default)', () => { + const exporter = new InMemoryLogRecordExporter(); + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(exporter)], + }); + const logger = loggerProvider.getLogger('test-logger') as Logger; + + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.NONE, + }; + const activeContext = trace.setSpanContext(ROOT_CONTEXT, spanContext); + + logger.emit({ + body: 'unsampled trace message', + context: activeContext, + }); + + const logRecords = exporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + }); + + it('should combine trace-based and minimum severity filtering', () => { + const exporter = new InMemoryLogRecordExporter(); + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(exporter)], + loggerConfigurator: createLoggerConfigurator([ + { + pattern: 'test-logger', + config: { + traceBased: true, + minimumSeverity: SeverityNumber.WARN, + }, + }, + ]), + }); + const logger = loggerProvider.getLogger('test-logger') as Logger; + + const sampledSpanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + const sampledContext = trace.setSpanContext( + ROOT_CONTEXT, + sampledSpanContext + ); + + const unsampledSpanContext = { + traceId: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4', + spanId: '7f8e9d0c1b2a3f4e', + traceFlags: TraceFlags.NONE, + }; + const unsampledContext = trace.setSpanContext( + ROOT_CONTEXT, + unsampledSpanContext + ); + + // Should be emitted (sampled + severity >= minimum) + logger.emit({ + body: 'sampled warn', + severityNumber: SeverityNumber.WARN, + context: sampledContext, + }); + + // Should be dropped (sampled but severity < minimum) + logger.emit({ + body: 'sampled info', + severityNumber: SeverityNumber.INFO, + context: sampledContext, + }); + + // Should be dropped (severity >= minimum but unsampled) + logger.emit({ + body: 'unsampled error', + severityNumber: SeverityNumber.ERROR, + context: unsampledContext, + }); + + // Should be dropped (unsampled + severity < minimum) + logger.emit({ + body: 'unsampled debug', + severityNumber: SeverityNumber.DEBUG, + context: unsampledContext, + }); + + const logRecords = exporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual(logRecords[0].body, 'sampled warn'); + }); + }); }); }); From cfd6b7f24c78f919a9cdb1ba5d16c7ee6dab8934 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Mon, 6 Oct 2025 19:12:04 -0700 Subject: [PATCH 2/5] Update README.md --- experimental/packages/sdk-logs/README.md | 120 +++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/experimental/packages/sdk-logs/README.md b/experimental/packages/sdk-logs/README.md index 8172cede2aa..f6b87d2a4e9 100644 --- a/experimental/packages/sdk-logs/README.md +++ b/experimental/packages/sdk-logs/README.md @@ -57,6 +57,126 @@ logger.emit({ Logs configuration is a merge of both the user supplied configuration and the default configuration as specified in [config.ts](./src/config.ts) +## Logger Configuration + +The SDK supports advanced logger configuration through the `LoggerConfigurator` API, which allows you to: + +- **Filter logs by minimum severity level** - Drop logs below a configured severity threshold +- **Filter logs by trace sampling status** - Drop logs associated with unsampled traces +- **Configure per-logger patterns** - Apply different configurations to different loggers using wildcard patterns + +### Minimum Severity Filtering + +Filter logs based on their severity level. Logs with severity below the configured minimum will be dropped before reaching the processor/exporter. + +```js +const { LoggerProvider, createLoggerConfigurator } = require('@opentelemetry/sdk-logs'); +const { SeverityNumber } = require('@opentelemetry/api-logs'); + +const loggerProvider = new LoggerProvider({ + loggerConfigurator: createLoggerConfigurator([ + { + pattern: '*', // Match all loggers + config: { + minimumSeverity: SeverityNumber.WARN // Only WARN, ERROR, and FATAL logs + } + } + ]), + processors: [new SimpleLogRecordProcessor(exporter)] +}); +``` + +**Behavior:** +- Logs with `severityNumber >= minimumSeverity` are exported +- Logs with `severityNumber = UNSPECIFIED` (0) or undefined always bypass the filter +- Default minimum severity is `UNSPECIFIED` (no filtering) + +### Trace-Based Filtering + +Filter logs based on their associated trace's sampling status. Logs from unsampled traces can be dropped to reduce volume while keeping sampled trace logs. + +```js +const loggerProvider = new LoggerProvider({ + loggerConfigurator: createLoggerConfigurator([ + { + pattern: '*', + config: { + traceBased: true // Drop logs from unsampled traces + } + } + ]), + processors: [new SimpleLogRecordProcessor(exporter)] +}); +``` + +**Behavior:** +- Logs associated with **sampled traces** (TraceFlags.SAMPLED set) are exported +- Logs associated with **unsampled traces** (TraceFlags.SAMPLED not set) are dropped +- Logs **without trace context** bypass the filter and are exported +- Default is `false` (no trace-based filtering) + +### Combined Filtering + +Both filters can be combined. A log must pass **both** filters to be exported. + +```js +const loggerProvider = new LoggerProvider({ + loggerConfigurator: createLoggerConfigurator([ + { + pattern: '*', + config: { + minimumSeverity: SeverityNumber.WARN, // Filter by severity + traceBased: true // AND filter by trace sampling + } + } + ]), + processors: [new SimpleLogRecordProcessor(exporter)] +}); +``` + +### Per-Logger Configuration + +Use pattern matching to configure different loggers differently. Patterns are matched in order, and the first match is used. + +```js +const loggerProvider = new LoggerProvider({ + loggerConfigurator: createLoggerConfigurator([ + { + pattern: 'critical-service', // Exact match + config: { minimumSeverity: SeverityNumber.ERROR } + }, + { + pattern: 'debug-*', // Wildcard match + config: { minimumSeverity: SeverityNumber.DEBUG } + }, + { + pattern: '*', // Default for all other loggers + config: { minimumSeverity: SeverityNumber.WARN } + } + ]) +}); + +// Different loggers get different configurations +const criticalLogger = loggerProvider.getLogger('critical-service'); // ERROR+ +const debugLogger = loggerProvider.getLogger('debug-api'); // DEBUG+ +const defaultLogger = loggerProvider.getLogger('my-service'); // WARN+ +``` + +### Configuration Options + +```typescript +interface LoggerConfig { + /** Drop logs with severity below this level (default: UNSPECIFIED = no filtering) */ + minimumSeverity?: SeverityNumber; + + /** Drop logs from unsampled traces (default: false) */ + traceBased?: boolean; + + /** Disable this logger completely (default: false) */ + disabled?: boolean; +} +``` + ## Example See [examples/logs](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/examples/logs) From 5ac79211bb89396c884031332a554bf1674cce8c Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Mon, 6 Oct 2025 19:15:50 -0700 Subject: [PATCH 3/5] Update CHANGELOG.md --- experimental/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 7b2f6d97a71..00bf2521194 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -26,6 +26,7 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 * feat(opentelemetry-configuration): Parse of Configuration File [#5875](https://github.com/open-telemetry/opentelemetry-js/pull/5875) @maryliag * feat(opentelemetry-configuration): parse of array objects on configuration file [#5947](https://github.com/open-telemetry/opentelemetry-js/pull/5947) @maryliag * feat(opentelemetry-configuration): parse of environment variables on configuration file [#5947](https://github.com/open-telemetry/opentelemetry-js/pull/5947) @maryliag +* feat(sdk-logs): add the `loggerConfigurator` including the ability to filter by minimum severity and trace [#5991](https://github.com/open-telemetry/opentelemetry-js/pull/5991) ### :bug: Bug Fixes From c407f31c6a8cdd2f00992de7403b97062e23a480 Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 7 Oct 2025 10:29:24 -0700 Subject: [PATCH 4/5] Fix lint & add @experimental tags. --- experimental/packages/sdk-logs/src/Logger.ts | 10 ++++++++-- .../sdk-logs/src/config/LoggerConfigurators.ts | 3 +++ .../sdk-logs/src/internal/LoggerProviderSharedState.ts | 2 ++ experimental/packages/sdk-logs/src/types.ts | 7 +++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/experimental/packages/sdk-logs/src/Logger.ts b/experimental/packages/sdk-logs/src/Logger.ts index d4ee1aea0f4..c2b1fc522a6 100644 --- a/experimental/packages/sdk-logs/src/Logger.ts +++ b/experimental/packages/sdk-logs/src/Logger.ts @@ -17,7 +17,12 @@ import type * as logsAPI from '@opentelemetry/api-logs'; import { SeverityNumber } from '@opentelemetry/api-logs'; import type { InstrumentationScope } from '@opentelemetry/core'; -import { context, trace, TraceFlags, isSpanContextValid } from '@opentelemetry/api'; +import { + context, + trace, + TraceFlags, + isSpanContextValid, +} from '@opentelemetry/api'; import { LogRecordImpl } from './LogRecordImpl'; import { LoggerProviderSharedState } from './internal/LoggerProviderSharedState'; @@ -57,7 +62,8 @@ export class Logger implements logsAPI.Logger { const spanContext = trace.getSpanContext(currentContext); if (spanContext && isSpanContextValid(spanContext)) { // Check if the trace is unsampled (SAMPLED flag is unset) - const isSampled = (spanContext.traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED; + const isSampled = + (spanContext.traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED; if (!isSampled) { // Log record is dropped due to trace-based filter return; diff --git a/experimental/packages/sdk-logs/src/config/LoggerConfigurators.ts b/experimental/packages/sdk-logs/src/config/LoggerConfigurators.ts index c84aa2e9b1f..c990ac47dd2 100644 --- a/experimental/packages/sdk-logs/src/config/LoggerConfigurators.ts +++ b/experimental/packages/sdk-logs/src/config/LoggerConfigurators.ts @@ -40,6 +40,8 @@ export interface LoggerPattern { /** * The configuration to apply to matching loggers. * Partial config is allowed; unspecified properties will use defaults. + * + * @experimental This feature is in development as per the OpenTelemetry specification. */ config: LoggerConfig; } @@ -54,6 +56,7 @@ export interface LoggerPattern { * * @param patterns - Array of logger patterns with their configurations * @returns A LoggerConfigurator function that computes complete LoggerConfig + * @experimental This feature is in development as per the OpenTelemetry specification. * * @example * ```typescript diff --git a/experimental/packages/sdk-logs/src/internal/LoggerProviderSharedState.ts b/experimental/packages/sdk-logs/src/internal/LoggerProviderSharedState.ts index 60edb776427..36cc15f0ad0 100644 --- a/experimental/packages/sdk-logs/src/internal/LoggerProviderSharedState.ts +++ b/experimental/packages/sdk-logs/src/internal/LoggerProviderSharedState.ts @@ -68,6 +68,8 @@ export class LoggerProviderSharedState { * Get the LoggerConfig for a given instrumentation scope. * Uses the LoggerConfigurator function to compute the config on first access * and caches the result. + * + * @experimental This feature is in development as per the OpenTelemetry specification. */ getLoggerConfig( instrumentationScope: InstrumentationScope diff --git a/experimental/packages/sdk-logs/src/types.ts b/experimental/packages/sdk-logs/src/types.ts index 999ccc6828f..5d57efd6c0a 100644 --- a/experimental/packages/sdk-logs/src/types.ts +++ b/experimental/packages/sdk-logs/src/types.ts @@ -35,6 +35,8 @@ export interface LoggerConfig { * If not explicitly set, defaults to 0 (UNSPECIFIED). * Log records with a specified severity (i.e. not 0) that is less than this value will be dropped. * Log records with unspecified severity (0) bypass this filter. + * + * @experimental This feature is in development as per the OpenTelemetry specification. */ minimumSeverity?: SeverityNumber; @@ -43,6 +45,8 @@ export interface LoggerConfig { * associated with sampled traces. * If not explicitly set, defaults to false. * If true, log records associated with unsampled traces will be dropped. + * + * @experimental This feature is in development as per the OpenTelemetry specification. */ traceBased?: boolean; } @@ -57,6 +61,7 @@ export interface LoggerConfig { * * @param loggerScope - The InstrumentationScope of the Logger * @returns The computed LoggerConfig with all properties set + * @experimental This feature is in development as per the OpenTelemetry specification. */ export type LoggerConfigurator = ( loggerScope: InstrumentationScope @@ -81,6 +86,8 @@ export interface LoggerProviderConfig { /** * A function that computes the LoggerConfig for a given logger. * This is called when a Logger is first created. + * + * @experimental This feature is in development as per the OpenTelemetry specification. */ loggerConfigurator?: LoggerConfigurator; } From 8e25840614d30a7449c528328e1791248087c68a Mon Sep 17 00:00:00 2001 From: Jackson Weber Date: Tue, 7 Oct 2025 10:55:31 -0700 Subject: [PATCH 5/5] Update README.md --- experimental/packages/sdk-logs/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/experimental/packages/sdk-logs/README.md b/experimental/packages/sdk-logs/README.md index f6b87d2a4e9..2b4f4397292 100644 --- a/experimental/packages/sdk-logs/README.md +++ b/experimental/packages/sdk-logs/README.md @@ -87,6 +87,7 @@ const loggerProvider = new LoggerProvider({ ``` **Behavior:** + - Logs with `severityNumber >= minimumSeverity` are exported - Logs with `severityNumber = UNSPECIFIED` (0) or undefined always bypass the filter - Default minimum severity is `UNSPECIFIED` (no filtering) @@ -110,6 +111,7 @@ const loggerProvider = new LoggerProvider({ ``` **Behavior:** + - Logs associated with **sampled traces** (TraceFlags.SAMPLED set) are exported - Logs associated with **unsampled traces** (TraceFlags.SAMPLED not set) are dropped - Logs **without trace context** bypass the filter and are exported