UNPKG

autotel

Version:
343 lines (313 loc) 9.84 kB
/** * YAML configuration loader for autotel * * Supports: * - Auto-discovery of autotel.yaml in cwd * - AUTOTEL_CONFIG_FILE env var override * - Environment variable substitution: ${env:VAR} and ${env:VAR:-default} * * @example Auto-discovery * ```yaml * # autotel.yaml in project root * service: * name: my-service * exporter: * endpoint: ${env:OTEL_EXPORTER_OTLP_ENDPOINT:-http://localhost:4318} * ``` * * @example Explicit path * ```bash * AUTOTEL_CONFIG_FILE=./config/otel.yaml tsx --import autotel/auto src/index.ts * ``` */ import { readFileSync, existsSync } from 'node:fs'; import path from 'node:path'; import type { AutotelConfig } from './init'; import { AdaptiveSampler, AlwaysSampler, NeverSampler, RandomSampler, type SamplingPreset, } from './sampling'; /** * Lazy-load yaml parser (optional peer dependency) * Only loads when a YAML config file is actually found */ import { requireModule } from './node-require'; function loadYamlParser(): (content: string) => unknown { try { const mod = requireModule<{ parse: (content: string) => unknown }>('yaml'); return mod.parse; } catch { throw new Error('YAML parser not found. Install with: pnpm add yaml'); } } /** * YAML config structure * Maps to AutotelConfig with user-friendly naming */ export interface YamlConfig { service?: { name?: string; version?: string; environment?: string; }; exporter?: { endpoint?: string; protocol?: 'http' | 'grpc'; headers?: Record<string, string>; }; resource?: Record<string, string | number | boolean>; sampling?: { preset?: SamplingPreset; type?: 'adaptive' | 'always_on' | 'always_off' | 'ratio'; ratio?: number; baseline_rate?: number; always_sample_errors?: boolean; always_sample_slow?: boolean; slow_threshold_ms?: number; }; autoInstrumentations?: string[] | Record<string, { enabled?: boolean }>; debug?: boolean; } /** * Environment variable substitution regex * Matches ${env:VAR_NAME} and ${env:VAR_NAME:-default} */ const ENV_VAR_PATTERN = /\$\{env:([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g; /** * Substitute ${env:VAR} and ${env:VAR:-default} in a string * * @param value - String potentially containing env var references * @returns String with env vars substituted * * @example * substituteEnvVars('${env:NODE_ENV:-development}') * // Returns 'production' if NODE_ENV=production, else 'development' */ function substituteEnvVars(value: string): string { return value.replaceAll( ENV_VAR_PATTERN, (_match, varName: string, defaultValue?: string) => { const envValue = process.env[varName]; if (envValue !== undefined) return envValue; if (defaultValue !== undefined) return defaultValue; console.warn( `[autotel] Environment variable ${varName} not set and no default provided`, ); return ''; }, ); } /** * Recursively substitute env vars in an object * * @param obj - Object to process * @returns Object with all string values having env vars substituted */ function substituteEnvVarsDeep(obj: unknown): unknown { if (typeof obj === 'string') { return substituteEnvVars(obj); } if (Array.isArray(obj)) { return obj.map((item) => substituteEnvVarsDeep(item)); } if (obj && typeof obj === 'object') { const result: Record<string, unknown> = {}; for (const [key, value] of Object.entries(obj)) { result[key] = substituteEnvVarsDeep(value); } return result; } return obj; } /** * Find YAML config file path * * Priority: * 1. AUTOTEL_CONFIG_FILE env var (explicit path) * 2. autotel.yaml in cwd (convention) * 3. autotel.yml in cwd (alternative extension) * * @returns File path if found, null otherwise */ function findConfigFile(): string | null { // Check env var first (explicit takes priority) const envPath = process.env.AUTOTEL_CONFIG_FILE; if (envPath) { const resolved = path.resolve(envPath); if (existsSync(resolved)) return resolved; console.warn(`[autotel] Config file not found: ${envPath}`); return null; } // Auto-discover autotel.yaml in cwd const conventionPath = path.resolve(process.cwd(), 'autotel.yaml'); if (existsSync(conventionPath)) return conventionPath; // Also check .yml extension const altPath = path.resolve(process.cwd(), 'autotel.yml'); if (existsSync(altPath)) return altPath; return null; } /** * Convert YAML config structure to AutotelConfig * * @param yaml - Parsed and env-substituted YAML config * @returns Partial AutotelConfig ready for merging */ function yamlToAutotelConfig(yaml: YamlConfig): Partial<AutotelConfig> { const config: Partial<AutotelConfig> = {}; // Service configuration if (yaml.service?.name) config.service = yaml.service.name; if (yaml.service?.version) config.version = yaml.service.version; if (yaml.service?.environment) config.environment = yaml.service.environment; // Exporter configuration if (yaml.exporter?.endpoint) config.endpoint = yaml.exporter.endpoint; if (yaml.exporter?.protocol) config.protocol = yaml.exporter.protocol; if (yaml.exporter?.headers) config.headers = yaml.exporter.headers; // Resource attributes (flattened) if (yaml.resource) config.resourceAttributes = yaml.resource; // Integrations if (yaml.autoInstrumentations) config.autoInstrumentations = yaml.autoInstrumentations; // Debug mode if (yaml.debug !== undefined) config.debug = yaml.debug; // Sampling configuration if (yaml.sampling?.preset) { warnOnIgnoredPresetOverrides(yaml.sampling); config.sampling = yaml.sampling.preset; } else { const sampler = createSamplerFromYaml(yaml.sampling); if (sampler) config.sampler = sampler; } return config; } function createSamplerFromYaml( sampling?: YamlConfig['sampling'], ): AutotelConfig['sampler'] { if (!sampling) return undefined; if (sampling.preset) return undefined; const type = sampling.type ?? 'adaptive'; try { switch (type) { case 'adaptive': { return new AdaptiveSampler({ baselineSampleRate: sampling.baseline_rate, alwaysSampleErrors: sampling.always_sample_errors, alwaysSampleSlow: sampling.always_sample_slow, slowThresholdMs: sampling.slow_threshold_ms, }); } case 'always_on': { return new AlwaysSampler(); } case 'always_off': { return new NeverSampler(); } case 'ratio': { if (sampling.ratio === undefined) { console.warn( '[autotel] sampling.ratio missing in YAML sampling config. Falling back to adaptive sampler.', ); return new AdaptiveSampler(); } return new RandomSampler(sampling.ratio); } default: { console.warn( `[autotel] Unknown sampling type "${type}" in YAML config. Falling back to defaults.`, ); return undefined; } } } catch (error) { console.warn( `[autotel] Failed to configure sampling from YAML: ${error instanceof Error ? error.message : String(error)}`, ); return undefined; } } function warnOnIgnoredPresetOverrides( sampling: NonNullable<YamlConfig['sampling']>, ): void { const ignoredFields = [ 'type', 'ratio', 'baseline_rate', 'always_sample_errors', 'always_sample_slow', 'slow_threshold_ms', ].filter((field) => sampling[field as keyof typeof sampling] !== undefined); if (ignoredFields.length === 0) { return; } console.warn( `[autotel] sampling.preset="${sampling.preset}" ignores these YAML fields: ${ignoredFields.join(', ')}. ` + 'Use the programmatic API with sampler or samplingPresets.*(...) for tuned presets.', ); } /** * Load and parse YAML config file (auto-discovery) * * Automatically finds and loads autotel.yaml or uses AUTOTEL_CONFIG_FILE. * Returns null if no config file found (not an error - YAML config is optional). * * @returns Partial AutotelConfig or null if no config file found * * @example * const yamlConfig = loadYamlConfig(); * if (yamlConfig) { * init({ ...yamlConfig, debug: true }); * } */ export function loadYamlConfig(): Partial<AutotelConfig> | null { const filePath = findConfigFile(); if (!filePath) return null; try { const content = readFileSync(filePath, 'utf8'); const parseYaml = loadYamlParser(); const rawYaml = parseYaml(content) as YamlConfig; const substituted = substituteEnvVarsDeep(rawYaml) as YamlConfig; return yamlToAutotelConfig(substituted); } catch (error) { console.error( `[autotel] Failed to load YAML config from ${filePath}:`, error, ); return null; } } /** * Load YAML config from a specific file path * * Unlike loadYamlConfig(), this throws if the file cannot be read. * * @param filePath - Path to YAML config file * @returns Partial AutotelConfig * @throws Error if file cannot be read or parsed * * @example * import { loadYamlConfigFromFile } from 'autotel/yaml'; * import { init } from 'autotel'; * * const config = loadYamlConfigFromFile('./config/otel.yaml'); * init({ ...config, debug: true }); */ export function loadYamlConfigFromFile( filePath: string, ): Partial<AutotelConfig> { const resolved = path.resolve(filePath); const content = readFileSync(resolved, 'utf8'); const parseYaml = loadYamlParser(); const rawYaml = parseYaml(content) as YamlConfig; const substituted = substituteEnvVarsDeep(rawYaml) as YamlConfig; return yamlToAutotelConfig(substituted); } /** * Check if a YAML config file exists (without loading it) * * @returns true if a config file would be found by loadYamlConfig() */ export function hasYamlConfig(): boolean { return findConfigFile() !== null; }