diff --git a/src/config/base.config.ts b/src/config/base.config.ts new file mode 100644 index 0000000..804783c --- /dev/null +++ b/src/config/base.config.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; +import { config } from './global.config.js'; + +/** + * Base configuration class with common functionality + */ +export abstract class BaseConfig { + protected abstract schema: z.ZodSchema; + protected abstract configKey: string; + + protected getValue(key: string, defaultValue?: string): string | undefined { + return config.get(key, defaultValue); + } + + protected getBoolean(key: string, defaultValue: boolean = false): boolean { + return config.getBoolean(key, defaultValue); + } + + protected getNumber(key: string, defaultValue: number): number { + const value = this.getValue(key); + if (value === undefined) return defaultValue; + return Number(value); + } + + protected parseHeaders(headersString: string): Record { + const headers: Record = {}; + const pairs = headersString.split(','); + for (const pair of pairs) { + const [key, value] = pair.split('='); + if (key && value) { + headers[key.trim()] = value.trim(); + } + } + return headers; + } + + public load(): T { + return this.schema.parse(this.loadFromSources()); + } + + protected abstract loadFromSources(): Record; +} diff --git a/src/config/global.config.ts b/src/config/global.config.ts new file mode 100644 index 0000000..01dba4a --- /dev/null +++ b/src/config/global.config.ts @@ -0,0 +1,130 @@ +import fs from 'fs'; +import path from 'path'; +import { Logger } from '../utils/logger.util.js'; +import dotenv from 'dotenv'; +import os from 'os'; + +/** + * Configuration loader that handles multiple sources with priority: + * 1. Direct ENV pass (process.env) + * 2. .env file in project root + * 3. Global config file at $HOME/.mcp/configs.json + */ +class ConfigLoader { + private packageName: string; + private configLoaded: boolean = false; + + constructor(packageName: string) { + this.packageName = packageName; + } + + load(): void { + const logger = Logger.forContext('global.config', 'load'); + + if (this.configLoaded) { + logger.debug('Configuration already loaded, skipping'); + return; + } + + logger.debug('Loading configuration...'); + + // Load from global config file + this.loadFromGlobalConfig(); + + // Load from .env file + this.loadFromEnvFile(); + + this.configLoaded = true; + logger.debug('Configuration loaded successfully'); + } + + private loadFromEnvFile(): void { + const logger = Logger.forContext('global.config', 'loadFromEnvFile'); + + try { + const result = dotenv.config(); + if (result.error) { + logger.debug('No .env file found or error reading it'); + return; + } + logger.debug('Loaded configuration from .env file'); + } catch (error) { + logger.error('Error loading .env极 file', error); + } + } + + private loadFromGlobalConfig(): void { + const logger = Logger.forContext( + 'global.config', + 'loadFromGlobalConfig', + ); + + try { + const homedir = os.homedir(); + const globalConfigPath = path.join(homedir, '.mcp', 'configs.json'); + + if (!fs.existsSync(globalConfigPath)) { + logger.debug('Global config file not found'); + return; + } + + const configContent = fs.readFileSync(globalConfigPath, 'utf8'); + const config = JSON.parse(configContent); + + const shortKey = 'llm-observability-mcp'; + const fullPackageName = this.packageName; + const unscopedPackageName = + fullPackageName.split('/')[1] || fullPackageName; + + const potentialKeys = [ + shortKey, + fullPackageName, + unscopedPackageName, + ]; + let foundConfig = null; + + for (const key of potentialKeys) { + if ( + config[key] && + typeof config[key] === 'object' && + config[key].environments + ) { + foundConfig = config[key]; + logger.debug(`Found configuration using key: ${key}`); + break; + } + } + + if (!foundConfig || !foundConfig.environments) { + logger.debug( + `No config found for ${this.packageName} using keys: ${potentialKeys.join(', ')}`, + ); + return; + } + + for (const [key, value] of Object.entries( + foundConfig.environments, + )) { + if (process.env[key] === undefined) { + process.env[key] = String(value); + } + } + + logger.debug(`Loaded configuration from global config file`); + } catch (error) { + logger.error('Error loading global config file', error); + } + } + + get(key: string, defaultValue?: string): string | undefined { + return process.env[key] || defaultValue; + } + + getBoolean(key: string, defaultValue: boolean = false): boolean { + const value = this.get(key); + if (value === undefined) return defaultValue; + return value.toLowerCase() === 'true'; + } +} + +export const config = new ConfigLoader('@sfiorini/llm-observability-mcp'); diff --git a/src/config/opentelemetry-llm.config.ts b/src/config/opentelemetry-llm.config.ts index 81543f9..3fcdf3a 100644 --- a/src/config/opentelemetry-llm.config.ts +++ b/src/config/opentelemetry-llm.config.ts @@ -1,130 +1,82 @@ +import { BaseConfig } from './base.config.js'; +import { + OpenTelemetryConfigSchema, + type OpenTelemetryConfigType, +} from './opentelemetry-llm.schema.js'; import { z } from 'zod'; -/** - * Configuration schema for OpenTelemetry - */ -export const OpenTelemetryConfigSchema = z.object({ - // Service configuration - serviceName: z.string().default('llm-observability-mcp'), - serviceVersion: z.string().default('1.0.0'), - environment: z.string().default('development'), - - // OTLP endpoints - metricsEndpoint: z.string().optional(), - tracesEndpoint: z.string().optional(), - logsEndpoint: z.string().optional(), - - // Authentication headers - headers: z.record(z.string()).optional(), - - // Export configuration - exportIntervalMillis: z.number().default(10000), - exportTimeoutMillis: z.number().default(5000), - - // Sampling configuration - samplingRatio: z.number().min(0).max(1).default(1.0), -}); - -export type OpenTelemetryConfigType = z.infer; - /** * OpenTelemetry configuration class */ -export class OpenTelemetryConfig { - public readonly serviceName: string; - public readonly serviceVersion: string; - public readonly environment: string; - public readonly metricsEndpoint?: string; - public readonly tracesEndpoint?: string; - public readonly logsEndpoint?: string; - public readonly headers?: Record; - public readonly exportIntervalMillis: number; - public readonly exportTimeoutMillis: number; - public readonly samplingRatio: number; +export class OpenTelemetryConfig extends BaseConfig { + protected schema = + OpenTelemetryConfigSchema as z.ZodSchema; + protected configKey = 'opentelemetry'; - constructor(config: OpenTelemetryConfigType) { - this.serviceName = config.serviceName; - this.serviceVersion = config.serviceVersion; - this.environment = config.environment; - this.metricsEndpoint = config.metricsEndpoint; - this.tracesEndpoint = config.tracesEndpoint; - this.logsEndpoint = config.logsEndpoint; - this.headers = config.headers; - this.exportIntervalMillis = config.exportIntervalMillis; - this.exportTimeoutMillis = config.exportTimeoutMillis; - this.samplingRatio = config.samplingRatio; + constructor() { + super(); } /** * Create configuration from environment variables */ - public static fromEnv(): OpenTelemetryConfig { - const config = OpenTelemetryConfigSchema.parse({ - serviceName: - process.env.OTEL_SERVICE_NAME || 'llm-observability-mcp', - serviceVersion: process.env.OTEL_SERVICE_VERSION || '1.0.0', - environment: process.env.OTEL_ENVIRONMENT || 'development', - metricsEndpoint: process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, - tracesEndpoint: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, - logsEndpoint: process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, - headers: process.env.OTEL_EXPORTER_OTLP_HEADERS - ? parseHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS) - : undefined, - exportIntervalMillis: parseInt( - process.env.OTEL_METRIC_EXPORT_INTERVAL || '10000', - 10, - ), - exportTimeoutMillis: parseInt( - process.env.OTEL_METRIC_EXPORT_TIMEOUT || '5000', - 10, - ), - samplingRatio: parseFloat( - process.env.OTEL_TRACES_SAMPLER_ARG || '1.0', - ), - }); - - return new OpenTelemetryConfig(config); + public fromEnv(): OpenTelemetryConfigType { + return super.load(); } /** * Check if OpenTelemetry is enabled */ - public isEnabled(): boolean { + public isEnabled(config: OpenTelemetryConfigType): boolean { return !!( - this.metricsEndpoint || - this.tracesEndpoint || - this.logsEndpoint + config.metricsEndpoint || + config.tracesEndpoint || + config.logsEndpoint ); } /** * Get combined OTLP endpoint if only one is provided */ - public getDefaultEndpoint(): string | undefined { + public getDefaultEndpoint( + config: OpenTelemetryConfigType, + ): string | undefined { if ( - this.metricsEndpoint && - this.tracesEndpoint === this.metricsEndpoint + config.metricsEndpoint && + config.tracesEndpoint === config.metricsEndpoint ) { - return this.metricsEndpoint; - } - return this.metricsEndpoint || this.tracesEndpoint; - } -} - -/** - * Parse headers from environment variable string - * Format: "key1=value1,key2=value2" - */ -function parseHeaders(headersString: string): Record { - const headers: Record = {}; - - const pairs = headersString.split(','); - for (const pair of pairs) { - const [key, value] = pair.split('='); - if (key && value) { - headers[key.trim()] = value.trim(); + return config.metricsEndpoint; } + return config.metricsEndpoint || config.tracesEndpoint; } - return headers; + protected loadFromSources(): Record { + return { + serviceName: this.getValue( + 'OTEL_SERVICE_NAME', + 'llm-observability-mcp', + ), + serviceVersion: this.getValue('OTEL_SERVICE_VERSION', '1.0.0'), + environment: this.getValue('OTEL_ENVIRONMENT', 'development'), + metricsEndpoint: this.getValue( + 'OTEL_EXPORTER_OTLP_METRICS_ENDPOINT', + ), + tracesEndpoint: this.getValue('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT'), + logsEndpoint: this.getValue('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT'), + headers: this.getValue('OTEL_EXPORTER_OTLP_HEADERS') + ? this.parseHeaders( + this.getValue('OTEL_EXPORTER_OTLP_HEADERS')!, + ) + : undefined, + exportIntervalMillis: this.getNumber( + 'OTEL_METRIC_EXPORT_INTERVAL', + 10000, + ), + exportTimeoutMillis: this.getNumber( + 'OTEL_METRIC_EXPORT_TIMEOUT', + 5000, + ), + samplingRatio: this.getNumber('OTEL_TRACES_SAMPLER_ARG', 1.0), + }; + } } diff --git a/src/config/opentelemetry-llm.schema.ts b/src/config/opentelemetry-llm.schema.ts new file mode 100644 index 0000000..52adf34 --- /dev/null +++ b/src/config/opentelemetry-llm.schema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +/** + * Configuration schema for OpenTelemetry + */ +export const OpenTelemetryConfigSchema = z.object({ + // Service configuration + serviceName: z.string().default('llm-observability-mcp'), + serviceVersion: z.string().default('1.0.0'), + environment: z.string().default('development'), + + // OTLP endpoints + metricsEndpoint: z.string().optional(), + tracesEndpoint: z.string().optional(), + logsEndpoint: z.string().optional(), + + // Authentication headers + headers: z.record(z.string()).optional(), + + // Export configuration + exportIntervalMillis: z.number().default(10000), + exportTimeoutMillis: z.number().default(5000), + + // Sampling configuration + samplingRatio: z.number().min(0).max(1).default(1.0), +}); + +export type OpenTelemetryConfigType = z.infer; diff --git a/src/index.ts b/src/index.ts index 1515eb2..a962174 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { Logger } from './utils/logger.util.js'; -import { config } from './utils/config.util.js'; +import { config } from './config/config.util.js'; import { runCli } from './cli/index.js'; import { stdioTransport } from './server/stdio.js'; import { streamableHttpTransport } from './server/streamableHttp.js'; diff --git a/src/services/opentelemetry-llm.service.ts b/src/services/opentelemetry-llm.service.ts index 11f1809..684f66c 100644 --- a/src/services/opentelemetry-llm.service.ts +++ b/src/services/opentelemetry-llm.service.ts @@ -13,7 +13,7 @@ import { SEMRESATTRS_DEPLOYMENT_ENVIRONMENT, } from '@opentelemetry/semantic-conventions'; import { Logger } from '../utils/logger.util.js'; -import { OpenTelemetryConfig } from '../config/opentelemetry-llm.config.js'; +import type { OpenTelemetryConfigType } from '../config/opentelemetry-llm.schema.js'; import { OpenTelemetryLlmInputSchemaType } from '../types/opentelemetry-llm.types.js'; import { Meter, @@ -33,7 +33,7 @@ export class OpenTelemetryService { private meterProvider: MeterProvider | null = null; private tracer: Tracer; private meter: Meter; - private config: OpenTelemetryConfig; + private config: OpenTelemetryConfigType; // Metrics private requestCounter: Counter; @@ -41,7 +41,7 @@ export class OpenTelemetryService { private latencyHistogram: Histogram; private activeRequests: UpDownCounter; - private constructor(config: OpenTelemetryConfig) { + private constructor(config: OpenTelemetryConfigType) { this.config = config; this.tracer = trace.getTracer('llm-observability-mcp'); this.meter = this.initializeMetrics(); @@ -75,7 +75,7 @@ export class OpenTelemetryService { * Get singleton instance */ public static getInstance( - config?: OpenTelemetryConfig, + config?: OpenTelemetryConfigType, ): OpenTelemetryService { if (!OpenTelemetryService.instance) { if (!config) { diff --git a/src/tools/opentelemetry-llm.tool.ts b/src/tools/opentelemetry-llm.tool.ts index d7d5f96..1f264b6 100644 --- a/src/tools/opentelemetry-llm.tool.ts +++ b/src/tools/opentelemetry-llm.tool.ts @@ -32,7 +32,8 @@ async function captureOpenTelemetryLlmObservability( const validatedArgs = OpenTelemetryLlmInputSchema.parse(args); // Ensure OpenTelemetry is configured - const config = OpenTelemetryConfig.fromEnv(); + const configLoader = new OpenTelemetryConfig(); + const config = configLoader.fromEnv(); const service = OpenTelemetryService.getInstance(config); // Initialize if not already done diff --git a/src/utils/config.util.ts b/src/utils/config.util.ts deleted file mode 100644 index 8bca54d..0000000 --- a/src/utils/config.util.ts +++ /dev/null @@ -1,173 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { Logger } from './logger.util.js'; -import dotenv from 'dotenv'; -import os from 'os'; - -/** - * Configuration loader that handles multiple sources with priority: - * 1. Direct ENV pass (process.env) - * 2. .env file in project root - * 3. Global config file at $HOME/.mcp/configs.json - */ -class ConfigLoader { - private packageName: string; - private configLoaded: boolean = false; - - /** - * Create a new ConfigLoader instance - * @param packageName The package name to use for global config lookup - */ - constructor(packageName: string) { - this.packageName = packageName; - } - - /** - * Load configuration from all sources with proper priority - */ - load(): void { - const methodLogger = Logger.forContext('utils/config.util.ts', 'load'); - - if (this.configLoaded) { - methodLogger.debug('Configuration already loaded, skipping'); - return; - } - - methodLogger.debug('Loading configuration...'); - - // Priority 3: Load from global config file - this.loadFromGlobalConfig(); - - // Priority 2: Load from .env file - this.loadFromEnvFile(); - - // Priority 1: Direct ENV pass is already in process.env - // No need to do anything as it already has highest priority - - this.configLoaded = true; - methodLogger.debug('Configuration loaded successfully'); - } - - /** - * Load configuration from .env file in project root - */ - private loadFromEnvFile(): void { - const methodLogger = Logger.forContext( - 'utils/config.util.ts', - 'loadFromEnvFile', - ); - - try { - const result = dotenv.config(); - if (result.error) { - methodLogger.debug('No .env file found or error reading it'); - return; - } - methodLogger.debug('Loaded configuration from .env file'); - } catch (error) { - methodLogger.error('Error loading .env file', error); - } - } - - /** - * Load configuration from global config file at $HOME/.mcp/configs.json - */ - private loadFromGlobalConfig(): void { - const methodLogger = Logger.forContext( - 'utils/config.util.ts', - 'loadFromGlobalConfig', - ); - - try { - const homedir = os.homedir(); - const globalConfigPath = path.join(homedir, '.mcp', 'configs.json'); - - if (!fs.existsSync(globalConfigPath)) { - methodLogger.debug('Global config file not found'); - return; - } - - const configContent = fs.readFileSync(globalConfigPath, 'utf8'); - const config = JSON.parse(configContent); - - // Determine the potential keys for the current package - const shortKey = 'llm-observability-mcp'; // Project-specific short key - const fullPackageName = this.packageName; // e.g., '@sfiorini/llm-observability-mcp' - const unscopedPackageName = - fullPackageName.split('/')[1] || fullPackageName; // e.g., 'llm-observability-mcp' - - const potentialKeys = [ - shortKey, - fullPackageName, - unscopedPackageName, - ]; - let foundConfigSection: { - environments?: Record; - } | null = null; - let usedKey: string | null = null; - - for (const key of potentialKeys) { - if ( - config[key] && - typeof config[key] === 'object' && - config[key].environments - ) { - foundConfigSection = config[key]; - usedKey = key; - methodLogger.debug(`Found configuration using key: ${key}`); - break; // Stop once found - } - } - - if (!foundConfigSection || !foundConfigSection.environments) { - methodLogger.debug( - `No configuration found for ${ - this.packageName - } using keys: ${potentialKeys.join(', ')}`, - ); - return; - } - - const environments = foundConfigSection.environments; - for (const [key, value] of Object.entries(environments)) { - // Only set if not already defined in process.env - if (process.env[key] === undefined) { - process.env[key] = String(value); - } - } - - methodLogger.debug( - `Loaded configuration from global config file using key: ${usedKey}`, - ); - } catch (error) { - methodLogger.error('Error loading global config file', error); - } - } - - /** - * Get a configuration value - * @param key The configuration key - * @param defaultValue The default value if the key is not found - * @returns The configuration value or the default value - */ - get(key: string, defaultValue?: string): string | undefined { - return process.env[key] || defaultValue; - } - - /** - * Get a boolean configuration value - * @param key The configuration key - * @param defaultValue The default value if the key is not found - * @returns The boolean configuration value or the default value - */ - getBoolean(key: string, defaultValue: boolean = false): boolean { - const value = this.get(key); - if (value === undefined) { - return defaultValue; - } - return value.toLowerCase() === 'true'; - } -} - -// Create and export a singleton instance with the package name from package.json -export const config = new ConfigLoader('@sfiorini/llm-observability-mcp');