autotel
Version:
Write Once, Observe Anywhere
343 lines (313 loc) • 9.84 kB
text/typescript
/**
* 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;
}