Refactor config loading (in progress)

This commit is contained in:
2025-07-14 17:29:44 -05:00
parent 1f201a093f
commit 82a509859d
8 changed files with 260 additions and 280 deletions

42
src/config/base.config.ts Normal file
View File

@@ -0,0 +1,42 @@
import { z } from 'zod';
import { config } from './global.config.js';
/**
* Base configuration class with common functionality
*/
export abstract class BaseConfig<T> {
protected abstract schema: z.ZodSchema<T>;
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<string, string> {
const headers: Record<string, string> = {};
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<string, unknown>;
}

130
src/config/global.config.ts Normal file
View File

@@ -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');

View File

@@ -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<typeof OpenTelemetryConfigSchema>;
/**
* 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<string, string>;
public readonly exportIntervalMillis: number;
public readonly exportTimeoutMillis: number;
public readonly samplingRatio: number;
export class OpenTelemetryConfig extends BaseConfig<OpenTelemetryConfigType> {
protected schema =
OpenTelemetryConfigSchema as z.ZodSchema<OpenTelemetryConfigType>;
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 config.metricsEndpoint;
}
return this.metricsEndpoint || this.tracesEndpoint;
return config.metricsEndpoint || config.tracesEndpoint;
}
protected loadFromSources(): Record<string, unknown> {
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),
};
}
}
/**
* Parse headers from environment variable string
* Format: "key1=value1,key2=value2"
*/
function parseHeaders(headersString: string): Record<string, string> {
const headers: Record<string, string> = {};
const pairs = headersString.split(',');
for (const pair of pairs) {
const [key, value] = pair.split('=');
if (key && value) {
headers[key.trim()] = value.trim();
}
}
return headers;
}

View File

@@ -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<typeof OpenTelemetryConfigSchema>;

View File

@@ -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';

View File

@@ -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) {

View File

@@ -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

View File

@@ -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<string, unknown>;
} | 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');