UNPKG

autotel

Version:
1,622 lines (1,511 loc) 68.2 kB
/** * Simplified initialization for autotel * * Single init() function with sensible defaults. * Replaces initInstrumentation() and separate events config. */ import { NodeSDK } from '@opentelemetry/sdk-node'; import type { NodeSDKConfiguration } from '@opentelemetry/sdk-node'; import { BatchSpanProcessor, type SpanProcessor, SimpleSpanProcessor, ConsoleSpanExporter, SamplingDecision, type SpanExporter, type Sampler as OtelSampler, type SamplingResult, } from '@opentelemetry/sdk-trace-base'; import { resourceFromAttributes, type Resource, } from '@opentelemetry/resources'; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, } from '@opentelemetry/semantic-conventions'; import type { Sampler, SamplingPreset } from './sampling'; import { samplingPresets, resolveSamplingPreset } from './sampling'; import type { EventSubscriber } from './event-subscriber'; import type { Logger } from './logger'; import type { Attributes, Context, SpanKind, Link } from '@opentelemetry/api'; import type { ValidationConfig } from './validation'; import { PeriodicExportingMetricReader, type MetricReader, } from '@opentelemetry/sdk-metrics'; import { OTLPMetricExporter as OTLPMetricExporterHTTP } from '@opentelemetry/exporter-metrics-otlp-http'; import { OTLPTraceExporter as OTLPTraceExporterHTTP } from '@opentelemetry/exporter-trace-otlp-http'; import { OTLPLogExporter as OTLPLogExporterHTTP } from '@opentelemetry/exporter-logs-otlp-http'; import type { PushMetricExporter } from '@opentelemetry/sdk-metrics'; import { BatchLogRecordProcessor, type LogRecordExporter, type LogRecordProcessor, } from '@opentelemetry/sdk-logs'; import { buildPostHogLogProcessors, RedactingLogRecordProcessor, } from './posthog-logs'; import { TailSamplingSpanProcessor } from './tail-sampling-processor'; import { BaggageSpanProcessor } from './baggage-span-processor'; import { FilteringSpanProcessor, type SpanFilterPredicate, } from './filtering-span-processor'; import { SpanNameNormalizingProcessor, type SpanNameNormalizerConfig, } from './span-name-normalizer'; import { AttributeRedactingProcessor, normalizeAttributeRedactorConfig, type AttributeRedactorConfig, type AttributeRedactorPreset, } from './attribute-redacting-processor'; import { createStringRedactor, type StringRedactor } from './redact-values'; import { PrettyConsoleExporter } from './pretty-console-exporter'; import { resolveConfigFromEnv } from './env-config'; import { loadYamlConfig } from './yaml-config'; import { requireModule, safeRequire } from './node-require'; import { CanonicalLogLineProcessor, type CanonicalLogLineOptions, } from './processors/canonical-log-line-processor'; import type { EventsConfig } from './events-config'; import { resolveDevtoolsConfig, type AutotelDevtoolsConfig } from './devtools'; /** * Silent logger (no-op) - used as default when user doesn't provide one. * Internal autotel logs are silent by default to avoid spam. * Users can import { autotelLogger } from 'autotel/logger' to create their own. */ const silentLogger: Logger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, }; /** * Adapts an Autotel Sampler to the OTel SDK Sampler interface. */ function toOtelSampler(sampler: Sampler): OtelSampler { return { shouldSample( _context: Context, _traceId: string, spanName: string, _spanKind: SpanKind, _attributes: Attributes, links: Link[], ): SamplingResult { const shouldTrace = sampler.shouldSample({ operationName: spanName, args: [], links, }); return { decision: shouldTrace ? SamplingDecision.RECORD_AND_SAMPLED : SamplingDecision.NOT_RECORD, }; }, toString(): string { return `AutotelSamplerAdapter`; }, }; } // Type imports for exporters type OTLPExporterConfig = { url?: string; headers?: Record<string, string>; timeoutMillis?: number; concurrencyLimit?: number; }; // Lazy-load gRPC exporters (optional peer dependencies) let OTLPTraceExporterGRPC: | (new (config: OTLPExporterConfig) => SpanExporter) | undefined; let OTLPMetricExporterGRPC: | (new (config: OTLPExporterConfig) => PushMetricExporter) | undefined; let OTLPLogExporterGRPC: | (new (config: OTLPExporterConfig) => LogRecordExporter) | undefined; /** * Helper: Lazy-load gRPC trace exporter */ function loadGRPCTraceExporter(): new ( config: OTLPExporterConfig, ) => SpanExporter { if (OTLPTraceExporterGRPC) return OTLPTraceExporterGRPC; try { // Dynamic import for optional peer dependency const grpcModule = requireModule<{ OTLPTraceExporter: new (config: OTLPExporterConfig) => SpanExporter; }>('@opentelemetry/exporter-trace-otlp-grpc'); OTLPTraceExporterGRPC = grpcModule.OTLPTraceExporter; return OTLPTraceExporterGRPC; } catch { throw new Error( 'gRPC trace exporter not found. Install @opentelemetry/exporter-trace-otlp-grpc', ); } } /** * Helper: Lazy-load gRPC metric exporter */ function loadGRPCMetricExporter(): new ( config: OTLPExporterConfig, ) => PushMetricExporter { if (OTLPMetricExporterGRPC) return OTLPMetricExporterGRPC; try { // Dynamic import for optional peer dependency const grpcModule = requireModule<{ OTLPMetricExporter: new ( config: OTLPExporterConfig, ) => PushMetricExporter; }>('@opentelemetry/exporter-metrics-otlp-grpc'); OTLPMetricExporterGRPC = grpcModule.OTLPMetricExporter; return OTLPMetricExporterGRPC; } catch { throw new Error( 'gRPC metric exporter not found. Install @opentelemetry/exporter-metrics-otlp-grpc', ); } } /** * Helper: Create trace exporter based on protocol */ function createTraceExporter( protocol: 'http' | 'grpc', config: OTLPExporterConfig, ): SpanExporter { if (protocol === 'grpc') { const Exporter = loadGRPCTraceExporter(); return new Exporter(config); } // Default: HTTP return new OTLPTraceExporterHTTP(config); } /** * Helper: Create metric exporter based on protocol */ function createMetricExporter( protocol: 'http' | 'grpc', config: OTLPExporterConfig, ): PushMetricExporter { if (protocol === 'grpc') { const Exporter = loadGRPCMetricExporter(); return new Exporter(config); } // Default: HTTP return new OTLPMetricExporterHTTP(config); } /** * Helper: Lazy-load gRPC log exporter */ function loadGRPCLogExporter(): new ( config: OTLPExporterConfig, ) => LogRecordExporter { if (OTLPLogExporterGRPC) return OTLPLogExporterGRPC; try { const grpcModule = requireModule<{ OTLPLogExporter: new (config: OTLPExporterConfig) => LogRecordExporter; }>('@opentelemetry/exporter-logs-otlp-grpc'); OTLPLogExporterGRPC = grpcModule.OTLPLogExporter; return OTLPLogExporterGRPC; } catch { throw new Error( 'gRPC log exporter not found. Install @opentelemetry/exporter-logs-otlp-grpc', ); } } /** * Helper: Create log exporter based on protocol */ function createLogExporter( protocol: 'http' | 'grpc', config: OTLPExporterConfig, ): LogRecordExporter { if (protocol === 'grpc') { const Exporter = loadGRPCLogExporter(); return new Exporter(config); } // Default: HTTP return new OTLPLogExporterHTTP(config); } /** * Helper: Resolve protocol from config and environment */ function resolveProtocol(configProtocol?: 'http' | 'grpc'): 'http' | 'grpc' { // 1. Check config parameter (highest priority) if (configProtocol === 'grpc' || configProtocol === 'http') { return configProtocol; } // 2. Check OTEL_EXPORTER_OTLP_PROTOCOL env var const envProtocol = process.env.OTEL_EXPORTER_OTLP_PROTOCOL; if (envProtocol === 'grpc') return 'grpc'; if (envProtocol === 'http/protobuf' || envProtocol === 'http') return 'http'; // 3. Default to HTTP return 'http'; } /** * Helper: Adjust endpoint URL for protocol * gRPC exporters don't need the /v1/traces or /v1/metrics path * HTTP exporters need the full path */ function formatEndpointUrl( endpoint: string, signal: 'traces' | 'metrics' | 'logs', protocol: 'http' | 'grpc', ): string { if (protocol === 'grpc') { // gRPC: strip any paths, return base endpoint return endpoint.replace(/\/(v1\/)?(traces|metrics|logs)$/, ''); } // HTTP: append signal path if not present if (!endpoint.endsWith(`/v1/${signal}`)) { return `${endpoint}/v1/${signal}`; } return endpoint; } // Built-in logger is created dynamically in init() with service name export interface AutotelConfig { /** Service name (required) */ service: string; /** * Local developer UX for autotel-devtools. * * - `true`: send traces, metrics, and logs to `http://127.0.0.1:4318` * - `{ embedded: true }`: attempt to start `autotel-devtools` automatically * * When enabled: * - `endpoint` defaults to the local devtools URL * - `logs` default to `true` unless explicitly set * * This keeps production config unchanged while making local debugging * effectively zero-config. */ devtools?: boolean | AutotelDevtoolsConfig; /** Event subscribers - bring your own (PostHog, Mixpanel, etc.) */ subscribers?: EventSubscriber[]; /** * Additional OpenTelemetry instrumentations to register (raw OTel classes). * Useful when you need custom instrumentation configs or instrumentations * not covered by autoInstrumentations. * * **Important:** If you need custom instrumentation configs (like `requireParentSpan: false`), * use EITHER manual instrumentations OR autoInstrumentations, not both for the same library. * Manual instrumentations always take precedence over auto-instrumentations. * * @example Manual instrumentations with custom config * ```typescript * import { MongoDBInstrumentation } from '@opentelemetry/instrumentation-mongodb' * * init({ * service: 'my-app', * autoInstrumentations: false, // Disable auto-instrumentations * instrumentations: [ * new MongoDBInstrumentation({ * requireParentSpan: false // Custom config * }) * ] * }) * ``` * * @example Mix auto + manual (auto for most, manual for specific configs) * ```typescript * import { MongoDBInstrumentation } from '@opentelemetry/instrumentation-mongodb' * * init({ * service: 'my-app', * autoInstrumentations: ['http', 'express'], // Auto for these * instrumentations: [ * new MongoDBInstrumentation({ * requireParentSpan: false // Manual config for MongoDB * }) * ] * }) * ``` */ instrumentations?: NodeSDKConfiguration['instrumentations']; /** * Simple names for auto-instrumentation. * Uses @opentelemetry/auto-instrumentations-node (peer dependency). * * **Important:** If you provide manual instrumentations for the same library, * the manual config takes precedence and auto-instrumentation for that library is disabled. * * @example Enable all auto-instrumentations (simple approach) * ```typescript * init({ * service: 'my-app', * autoInstrumentations: true // Enable all with defaults * }) * ``` * * @example Enable specific auto-instrumentations * ```typescript * init({ * service: 'my-app', * autoInstrumentations: ['express', 'pino', 'http'] * }) * ``` * * @example Configure specific auto-instrumentations * ```typescript * init({ * service: 'my-app', * autoInstrumentations: { * express: { enabled: true }, * pino: { enabled: true }, * http: { enabled: false } * } * }) * ``` * * @example Manual config when you need custom settings * ```typescript * import { MongoDBInstrumentation } from '@opentelemetry/instrumentation-mongodb' * * init({ * service: 'my-app', * autoInstrumentations: false, // Use manual control * instrumentations: [ * new MongoDBInstrumentation({ * requireParentSpan: false // Custom config not available with auto * }) * ] * }) * ``` */ autoInstrumentations?: | string[] | boolean | Record<string, { enabled?: boolean }>; /** * OTLP endpoint for traces/metrics/logs * Only used if you don't provide custom exporters/processors * @default process.env.OTLP_ENDPOINT || 'http://localhost:4318' */ endpoint?: string; /** * Custom span processors for traces (supports multiple processors) * Allows you to use any backend: Jaeger, Zipkin, Datadog, New Relic, etc. * If not provided, defaults to OTLP with tail sampling * * @example Multiple processors * ```typescript * import { JaegerExporter } from '@opentelemetry/exporter-jaeger' * import { BatchSpanProcessor, SimpleSpanProcessor, ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base' * * init({ * service: 'my-app', * spanProcessors: [ * new BatchSpanProcessor(new JaegerExporter()), * new SimpleSpanProcessor(new ConsoleSpanExporter()) // Debug alongside production * ] * }) * ``` * * @example Single processor * ```typescript * import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' * * init({ * service: 'my-app', * spanProcessors: [new SimpleSpanProcessor(new ConsoleSpanExporter())] * }) * ``` */ spanProcessors?: SpanProcessor[]; /** * Custom span exporters for traces (alternative to spanProcessors, supports multiple exporters) * Provide either spanProcessors OR spanExporters, not both * Each exporter will be wrapped in TailSamplingSpanProcessor + BatchSpanProcessor * * @example Multiple exporters * ```typescript * import { ZipkinExporter } from '@opentelemetry/exporter-zipkin' * import { JaegerExporter } from '@opentelemetry/exporter-jaeger' * * init({ * service: 'my-app', * spanExporters: [ * new ZipkinExporter({ url: 'http://localhost:9411/api/v2/spans' }), * new JaegerExporter() // Send to multiple backends simultaneously * ] * }) * ``` * * @example Single exporter * ```typescript * import { ZipkinExporter } from '@opentelemetry/exporter-zipkin' * * init({ * service: 'my-app', * spanExporters: [new ZipkinExporter({ url: 'http://localhost:9411/api/v2/spans' })] * }) * ``` */ spanExporters?: SpanExporter[]; /** * Custom metric readers (supports multiple readers) * Allows sending metrics to multiple backends: OTLP, Prometheus, custom readers * Defaults to OTLP metrics exporter when metrics are enabled. * * @example Multiple metric readers * ```typescript * import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' * import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http' * import { PrometheusExporter } from '@opentelemetry/exporter-prometheus' * * init({ * service: 'my-app', * metricReaders: [ * new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter() }), * new PrometheusExporter() // Export to multiple backends * ] * }) * ``` */ metricReaders?: MetricReader[]; /** * Custom log record processors. When omitted, logs are not configured. */ logRecordProcessors?: LogRecordProcessor[]; /** * PostHog integration - auto-configures OTLP log exporter. * * @example * ```typescript * init({ * service: 'my-app', * posthog: { url: 'https://us.i.posthog.com/i/v1/logs?token=phc_xxx' } * }); * ``` * * Also reads from POSTHOG_LOGS_URL environment variable as fallback. */ posthog?: { url: string }; /** Additional resource attributes to merge with defaults. */ resourceAttributes?: Attributes; /** Provide a fully custom Resource to merge (advanced use case). */ resource?: Resource; /** * Headers for OTLP exporters. Accepts either an object map or * a "key=value" comma separated string. * * @example * ```typescript * init({ * service: 'my-app', * endpoint: 'https://api.honeycomb.io', * headers: { 'x-honeycomb-team': 'YOUR_API_KEY' } * }) * ``` */ headers?: Record<string, string> | string; /** * OTLP protocol to use for traces, metrics, and logs * - 'http': HTTP/protobuf (default, uses port 4318) * - 'grpc': gRPC (uses port 4317) * * Can be overridden with OTEL_EXPORTER_OTLP_PROTOCOL env var. * * Note: gRPC exporters are optional peer dependencies. Install them with: * ```bash * pnpm add @opentelemetry/exporter-trace-otlp-grpc @opentelemetry/exporter-metrics-otlp-grpc * ``` * * @example HTTP (default) * ```typescript * init({ * service: 'my-app', * protocol: 'http', // or omit (defaults to http) * endpoint: 'http://localhost:4318' * }) * ``` * * @example gRPC * ```typescript * init({ * service: 'my-app', * protocol: 'grpc', * endpoint: 'grpc://localhost:4317' * }) * ``` * * @default 'http' */ protocol?: 'http' | 'grpc'; /** * Optional factory to build a customised NodeSDK instance from our defaults. */ sdkFactory?: (defaults: Partial<NodeSDKConfiguration>) => NodeSDK; /** * Infrastructure metrics configuration * - true: always enabled (default) * - false: always disabled * - 'auto': always enabled (same as true) * * Can be overridden with AUTOTEL_METRICS=on|off env var */ metrics?: boolean | 'auto'; /** * OTLP logs configuration * - true: auto-configure OTLP log exporter from endpoint * - false: disabled (default) * - 'auto': same as false (opt-in only) * * When enabled and an endpoint is configured, autotel will automatically * create a BatchLogRecordProcessor with an OTLPLogExporter - no manual * imports needed. Works alongside logRecordProcessors (additive). * * Requires @opentelemetry/sdk-logs and @opentelemetry/exporter-logs-otlp-http * (or -grpc) as peer dependencies. * * Can be overridden with AUTOTEL_LOGS=on|off env var. * * @example * ```typescript * init({ * service: 'my-app', * endpoint: 'http://localhost:4318', * logs: true, * }); * ``` */ logs?: boolean | 'auto'; /** Sampling strategy - takes precedence over `sampling` preset */ sampler?: Sampler; /** * Sampling preset shorthand — resolves to a pre-configured sampler. * If both `sampler` and `sampling` are provided, `sampler` takes precedence. * * @default 'production' */ sampling?: SamplingPreset; /** Service version (default: auto-detect from package.json or '1.0.0') */ version?: string; /** Environment (default: process.env.NODE_ENV || 'development') */ environment?: string; /** * Logger instance for internal autotel diagnostic messages * * This logger is used by autotel internally to log initialization, warnings, * and debug information. Any logger with info/warn/error/debug methods works. * * **For OTel instrumentation of your application logs**, use the `autoInstrumentations` option: * - `autoInstrumentations: ['pino']` - Injects traceId/spanId into Pino logs * - `autoInstrumentations: ['winston']` - Injects traceId/spanId into Winston logs * * Default: silent logger (no-op) * * @example Pino with OTel instrumentation * ```typescript * import pino from 'pino' * import { init } from 'autotel' * * const logger = pino({ level: 'info' }) * init({ * service: 'my-app', * logger, // For autotel's internal logs * autoInstrumentations: ['pino'] // For OTel trace context in YOUR logs * }) * ``` * * @example Custom logger for autotel diagnostics * ```typescript * const logger = { * info: (msg, extra) => console.log(msg, extra), * warn: (msg, extra) => console.warn(msg, extra), * error: (msg, err, extra) => console.error(msg, err, extra), * debug: (msg, extra) => console.debug(msg, extra), * } * init({ service: 'my-app', logger }) * ``` */ logger?: Logger; /** * Flush events queue when root spans end * - true: Flush on root span completion (default) * - false: Use batching (events flush every 10 seconds automatically) * * Only flushes on root spans to avoid excessive network calls. * Default is true for serverless/short-lived processes. Set to false * for long-running services where batching is more efficient. */ flushOnRootSpanEnd?: boolean; /** * Force-flush OpenTelemetry spans on shutdown (default: false) * * When enabled, spans are force-flushed along with events on root * span completion. This is useful for serverless/short-lived processes where * spans may not export before the process ends. * * - true: Force-flush spans on root span completion (~50-200ms latency) * - false: Spans export via normal batch processor (default behavior) * * Only applies when flushOnRootSpanEnd is also enabled. * * Note: For edge runtimes (Cloudflare Workers, Vercel Edge), use the * 'autotel-edge' package instead, which handles this automatically. * * @example Serverless with force-flush * ```typescript * init({ * service: 'my-lambda', * flushOnRootSpanEnd: true, * forceFlushOnShutdown: true, // Force-flush spans * }); * ``` */ forceFlushOnShutdown?: boolean; /** * Automatically copy baggage entries to span attributes * * When enabled, all baggage entries are automatically added as span attributes, * making them visible in trace UIs (Jaeger, Grafana, DataDog, etc.) without * manually calling ctx.setAttribute() for each entry. * * - `true`: adds baggage with 'baggage.' prefix (e.g. baggage.tenant.id) * - `string`: uses custom prefix (e.g. 'ctx' → ctx.tenant.id, '' → tenant.id) * - `false` or omit: disabled (default) * * @default false * * @example Enable with default prefix * ```typescript * init({ * service: 'my-app', * baggage: true * }); * * // Now baggage automatically appears as span attributes * await withBaggage({ * baggage: { 'tenant.id': 't1', 'user.id': 'u1' }, * fn: async () => { * // Span has baggage.tenant.id and baggage.user.id attributes! * } * }); * ``` * * @example Custom prefix * ```typescript * init({ * service: 'my-app', * baggage: 'ctx' // Uses 'ctx.' prefix * }); * // Creates attributes: ctx.tenant.id, ctx.user.id * ``` * * @example No prefix * ```typescript * init({ * service: 'my-app', * baggage: '' // No prefix * }); * // Creates attributes: tenant.id, user.id * ``` */ baggage?: boolean | string; /** * Validation configuration for events events * - Override default sensitive field patterns for redaction * - Customize max lengths, nesting depth, etc. * * @example Disable redaction for development * ```typescript * init({ * service: 'my-app', * validation: { * sensitivePatterns: [] // Disable all redaction * } * }) * ``` * * @example Add custom patterns * ```typescript * init({ * service: 'my-app', * validation: { * sensitivePatterns: [ * /password/i, * /apiKey/i, * /customSecret/i // Your custom pattern * ] * } * }) * ``` */ validation?: Partial<ValidationConfig>; /** * Events configuration for trace context, correlation IDs, and enrichment * * Controls how product events integrate with distributed tracing: * - `includeTraceContext`: Automatically include trace context in events * - `includeLinkedTraceIds`: Include full array of linked trace IDs (for batch/fan-in) * - `traceUrl`: Generate clickable trace URLs in events * - `enrichFromBaggage`: Auto-enrich events from baggage with guardrails * * @example Basic trace context * ```typescript * init({ * service: 'my-app', * events: { * includeTraceContext: true * } * }); * // Events now include autotel.trace_id, autotel.span_id, autotel.correlation_id * ``` * * @example With clickable trace URLs * ```typescript * init({ * service: 'my-app', * events: { * includeTraceContext: true, * traceUrl: (ctx) => `https://grafana.internal/explore?traceId=${ctx.traceId}` * } * }); * ``` * * @example With baggage enrichment * ```typescript * init({ * service: 'my-app', * events: { * includeTraceContext: true, * enrichFromBaggage: { * allow: ['tenant.id', 'user.id'], * prefix: 'ctx.', * maxKeys: 10, * maxBytes: 1024 * } * } * }); * ``` */ events?: EventsConfig; /** * Debug mode for local span inspection. * Enables console output to help you see spans as they're created. * * - `true`: Raw JSON output (ConsoleSpanExporter) * - `'pretty'`: Colorized, hierarchical output (PrettyConsoleExporter) * - `false`/undefined: No console output (default) * * When enabled: Outputs spans to console AND sends to backend (if endpoint/exporter configured) * * Perfect for progressive development: * - Start with debug: 'pretty' (no endpoint) → see traces immediately with nice formatting * - Add endpoint later → console + backend, verify before choosing provider * - Remove debug in production → backend only, clean production config * * Can be overridden with AUTOTEL_DEBUG environment variable. * * @example Pretty debug output (recommended for development) * ```typescript * init({ * service: 'my-app', * debug: 'pretty' // Colorized, hierarchical output * }) * ``` * * @example Raw JSON output (verbose) * ```typescript * init({ * service: 'my-app', * debug: true // Raw ConsoleSpanExporter output * }) * ``` * * @example Environment variable * ```bash * AUTOTEL_DEBUG=pretty node server.js * AUTOTEL_DEBUG=true node server.js * ``` */ debug?: boolean | 'pretty'; /** * Filter predicate to drop unwanted spans before processing. * * Useful for filtering out noisy spans from specific instrumentations * (e.g., Next.js internal spans, health check endpoints). * * The filter runs on completed spans (onEnd), so you have access to: * - `span.name` - Span name * - `span.attributes` - All span attributes * - `span.instrumentationScope` - `{ name, version }` of the instrumentation * - `span.status` - Span status code and message * - `span.duration` - Span duration as `[seconds, nanoseconds]` * * Return `true` to keep the span, `false` to drop it. * * @example Filter out Next.js instrumentation spans * ```typescript * init({ * service: 'my-app', * spanFilter: (span) => span.instrumentationScope.name !== 'next.js' * }) * ``` * * @example Filter out health check spans * ```typescript * init({ * service: 'my-app', * spanFilter: (span) => !span.name.includes('/health') * }) * ``` * * @example Complex filtering (multiple conditions) * ```typescript * init({ * service: 'my-app', * spanFilter: (span) => { * // Drop Next.js internal spans * if (span.instrumentationScope.name === 'next.js') return false; * // Drop health checks * if (span.name.includes('/health')) return false; * // Drop very short spans (less than 1ms) * const [secs, nanos] = span.duration; * if (secs === 0 && nanos < 1_000_000) return false; * return true; * } * }) * ``` */ spanFilter?: SpanFilterPredicate; /** * Normalize span names to reduce cardinality from dynamic path segments. * * High-cardinality span names (e.g., `/users/123/posts/456`) cause issues: * - Cost explosions in observability backends * - Cardinality limits exceeded * - Poor UX when searching/filtering traces * * The normalizer transforms dynamic segments into placeholders: * - `/users/123` → `/users/:id` * - `/items/550e8400-e29b-...` → `/items/:uuid` * * Provide either a custom function or use a built-in preset: * - `'rest-api'` - Numeric IDs, UUIDs, ObjectIds, dates, timestamps, emails * - `'graphql'` - GraphQL operation name normalization * - `'minimal'` - Only numeric IDs and UUIDs * * @example Custom normalizer function * ```typescript * init({ * service: 'my-app', * spanNameNormalizer: (name) => { * return name * .replace(/\/[0-9]+/g, '/:id') * .replace(/\/[a-f0-9-]{36}/gi, '/:uuid'); * } * }) * ``` * * @example Using built-in preset * ```typescript * init({ * service: 'my-app', * spanNameNormalizer: 'rest-api' * }) * ``` * * @example Combining with spanFilter * ```typescript * init({ * service: 'my-app', * spanNameNormalizer: 'rest-api', * spanFilter: (span) => span.instrumentationScope.name !== 'next.js' * }) * ``` */ spanNameNormalizer?: SpanNameNormalizerConfig; /** * Automatically redact PII and sensitive data from span attributes before export. * Critical for compliance (GDPR, PCI-DSS, HIPAA) and data security. * * Can be a preset name or custom configuration: * - `'default'`: Emails, phones, SSNs, credit cards, sensitive keys (password, secret, token) * - `'strict'`: Default + Bearer tokens, JWTs, API keys in values * - `'pci-dss'`: Payment card industry focus (credit cards, CVV, card-related keys) * * @example Use default preset * ```typescript * init({ * service: 'my-app', * attributeRedactor: 'default' * }) * ``` * * @example Custom patterns * ```typescript * init({ * service: 'my-app', * attributeRedactor: { * keyPatterns: [/password/i, /secret/i], * valuePatterns: [ * { name: 'customerId', pattern: /CUST-\d{8}/g, replacement: 'CUST-***' } * ] * } * }) * ``` * * @example Custom redactor function * ```typescript * init({ * service: 'my-app', * attributeRedactor: { * redactor: (key, value) => { * if (key === 'user.email' && typeof value === 'string') { * return value.replace(/@.+/, '@[REDACTED]'); * } * return value; * } * } * }) * ``` */ attributeRedactor?: AttributeRedactorConfig | AttributeRedactorPreset; /** * OpenLLMetry integration for LLM observability. * Requires @traceloop/node-server-sdk as an optional peer dependency. * * @example Enable OpenLLMetry with default settings * ```typescript * init({ * service: 'my-app', * openllmetry: { enabled: true } * }) * ``` * * @example Enable with custom options * ```typescript * init({ * service: 'my-app', * openllmetry: { * enabled: true, * options: { * disableBatch: process.env.NODE_ENV !== 'production', * apiKey: process.env.TRACELOOP_API_KEY * } * } * }) * ``` */ openllmetry?: { enabled: boolean; options?: Record<string, unknown>; }; /** * Canonical log lines - automatically emit spans as wide events (canonical log lines) * * When enabled, each span (or root span only) is automatically emitted as a * comprehensive log record with ALL span attributes. This implements the * "canonical log line" pattern: one comprehensive event per request with all context. * * **Benefits:** * - One log line per request with all context (wide event) * - High-cardinality, high-dimensionality data for powerful queries * - Automatic - no manual logging needed * - Queryable as structured data instead of string search * * @example Basic usage (one canonical log line per request) * ```typescript * init({ * service: 'checkout-api', * canonicalLogLines: { * enabled: true, * rootSpansOnly: true, // One canonical log line per request * }, * }); * ``` * * @example With custom logger * ```typescript * import pino from 'pino'; * const logger = pino(); * init({ * service: 'my-app', * logger, * canonicalLogLines: { * enabled: true, * logger, // Use Pino for canonical log lines * rootSpansOnly: true, * }, * }); * ``` * * @example Custom message format * ```typescript * init({ * service: 'my-app', * canonicalLogLines: { * enabled: true, * messageFormat: (span) => { * const status = span.status.code === 2 ? 'ERROR' : 'SUCCESS'; * return `${span.name} [${status}]`; * }, * }, * }); * ``` */ canonicalLogLines?: { enabled: boolean; /** Logger to use for emitting canonical log lines (defaults to OTel Logs API) */ logger?: Logger; /** Only emit canonical log lines for root spans (default: false) */ rootSpansOnly?: boolean; /** Minimum log level for canonical log lines (default: 'info') */ minLevel?: 'debug' | 'info' | 'warn' | 'error'; /** Custom message format (default: uses span name) */ messageFormat?: ( span: import('@opentelemetry/sdk-trace-base').ReadableSpan, ) => string; /** Whether to include resource attributes (default: true) */ includeResourceAttributes?: boolean; /** Predicate to decide whether to emit (runs after event is built). */ shouldEmit?: CanonicalLogLineOptions['shouldEmit']; /** * Declarative tail sampling conditions (OR logic). * Ignored when `shouldEmit` is provided. * @example keep: [{ status: 500 }, { durationMs: 1000 }] */ keep?: CanonicalLogLineOptions['keep']; /** Callback invoked after emit for custom fan-out. */ drain?: CanonicalLogLineOptions['drain']; /** Handler for drain failures. */ onDrainError?: CanonicalLogLineOptions['onDrainError']; /** * Pretty-print canonical log lines to console. * Defaults to true when NODE_ENV is 'development'. */ pretty?: boolean; }; /** * Suppress console output while keeping OTel exporters running. * Useful for platforms like GCP Cloud Run / AWS Lambda where stdout * is managed externally by the platform's log collector. * * @default false */ silent?: boolean; /** * Minimum log level for internal autotel diagnostic messages. * Messages below this level are dropped before processing. * * @default 'info' */ minLevel?: 'debug' | 'info' | 'warn' | 'error'; } // Internal state let initialized = false; let locked = false; let config: AutotelConfig | null = null; let sdk: NodeSDK | null = null; let warnedOnce = false; let logger: Logger = silentLogger; // Silent by default - no spam let validationConfig: Partial<ValidationConfig> | null = null; let eventsConfig: EventsConfig | null = null; let _stringRedactor: StringRedactor | null = null; let _optionalRequire: typeof safeRequire = safeRequire; let _devtoolsClose: (() => Promise<void> | void) | null = null; const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const; type LogLevelKey = keyof typeof LOG_LEVELS; /** * Lock the logger to prevent further `init()` calls. * Use this when framework plugins set up instrumentation and you want * to prevent accidental re-initialization from user code. */ export function lockLogger(): void { locked = true; } /** * Check if the logger has been locked. */ export function isLoggerLocked(): boolean { return locked; } function createSilentLogger(): Logger { return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, }; } function wrapLogger( base: Logger, silent: boolean, minLevel: LogLevelKey, ): Logger { if (silent) return createSilentLogger(); const threshold = LOG_LEVELS[minLevel]; const wrap = (fn: Logger['info'], level: LogLevelKey): Logger['info'] => { if (LOG_LEVELS[level] < threshold) { return (() => {}) as Logger['info']; } return ((...args: Parameters<Logger['info']>) => fn(...args)) as Logger['info']; }; return { debug: wrap(base.debug, 'debug'), info: wrap(base.info, 'info'), warn: wrap(base.warn, 'warn'), error: wrap(base.error, 'error'), }; } function detectEnvironmentAttributes(): Record<string, string> { const attrs: Record<string, string> = {}; const commitSha = process.env.COMMIT_SHA || process.env.GITHUB_SHA || process.env.VERCEL_GIT_COMMIT_SHA || process.env.CF_PAGES_COMMIT_SHA || process.env.AWS_CODEPIPELINE_EXECUTION_ID; if (commitSha) attrs['service.commit.sha'] = commitSha; const region = process.env.VERCEL_REGION || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || process.env.FLY_REGION || process.env.CF_REGION || process.env.GOOGLE_CLOUD_REGION; if (region) attrs['service.region'] = region; const version = process.env.APP_VERSION || process.env.HEROKU_RELEASE_VERSION || process.env.VERCEL_GIT_COMMIT_REF; if (version) attrs['service.deploy.version'] = version; return attrs; } /** * Resolve metrics flag with env var override support */ export function resolveMetricsFlag( configFlag: boolean | 'auto' = 'auto', ): boolean { // 1. Check env var override (highest priority) const envFlag = process.env.AUTOTEL_METRICS; if (envFlag === 'on' || envFlag === 'true') return true; if (envFlag === 'off' || envFlag === 'false') return false; // 2. Check config flag if (configFlag === true) return true; if (configFlag === false) return false; // 3. Default: enabled in all environments (simpler) return true; } /** * Resolve logs flag with env var override support. * Defaults to disabled (opt-in only) to avoid unexpected log export * and to preserve the upstream SDK's OTEL_LOGS_EXPORTER handling. */ export function resolveLogsFlag( configFlag: boolean | 'auto' = 'auto', ): boolean { // 1. Check env var override (highest priority) const envFlag = process.env.AUTOTEL_LOGS; if (envFlag === 'on' || envFlag === 'true') return true; if (envFlag === 'off' || envFlag === 'false') return false; // 2. Check config flag if (configFlag === true) return true; if (configFlag === false) return false; // 3. Default: disabled (opt-in only) return false; } /** * Resolve debug flag with env var override support * * Supports: * - `'pretty'`: Colorized, hierarchical output (PrettyConsoleExporter) * - `true` / `'true'` / `'1'`: Raw JSON output (ConsoleSpanExporter) * - `false` / `'false'` / `'0'`: Disabled */ export function resolveDebugFlag( configFlag?: boolean | 'pretty', ): boolean | 'pretty' { // 1. Check env var override (highest priority) const envFlag = process.env.AUTOTEL_DEBUG; if (envFlag === 'pretty') return 'pretty'; if (envFlag === 'true' || envFlag === '1') return true; if (envFlag === 'false' || envFlag === '0') return false; // 2. Return config flag (defaults to false) return configFlag ?? false; } function normalizeOtlpHeaders( headers?: Record<string, string> | string, ): Record<string, string> | undefined { if (!headers) return undefined; if (typeof headers !== 'string') return headers; const parsed: Record<string, string> = {}; for (const pair of headers.split(',')) { const [key, ...valueParts] = pair.split('='); if (!key || valueParts.length === 0) continue; parsed[key.trim()] = valueParts.join('=').trim(); } return parsed; } /** * Initialize autotel - Write Once, Observe Everywhere * * Follows OpenTelemetry standards: opinionated defaults with full flexibility * Idempotent: multiple calls are safe, last one wins * * @example Minimal setup (OTLP default) * ```typescript * init({ service: 'my-app' }) * ``` * * @example With events (observe in PostHog, Mixpanel, etc.) * ```typescript * import { PostHogSubscriber } from 'autotel-subscribers/posthog'; * * init({ * service: 'my-app', * subscribers: [new PostHogSubscriber({ apiKey: '...' })] * }) * ``` * * @example Observe in Jaeger * ```typescript * import { JaegerExporter } from '@opentelemetry/exporter-jaeger' * * init({ * service: 'my-app', * spanExporter: new JaegerExporter({ endpoint: 'http://localhost:14268/api/traces' }) * }) * ``` * * @example Observe in Zipkin * ```typescript * import { ZipkinExporter } from '@opentelemetry/exporter-zipkin' * * init({ * service: 'my-app', * spanExporter: new ZipkinExporter({ url: 'http://localhost:9411/api/v2/spans' }) * }) * ``` * * @example Observe in Datadog * ```typescript * import { DatadogSpanProcessor } from '@opentelemetry/exporter-datadog' * * init({ * service: 'my-app', * spanProcessor: new DatadogSpanProcessor({ ... }) * }) * ``` * * @example Console output (dev) * ```typescript * import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' * * init({ * service: 'my-app', * spanProcessor: new SimpleSpanProcessor(new ConsoleSpanExporter()) * }) * ``` */ export function init(cfg: AutotelConfig): void { if (locked) { return; } // Resolve configs in priority order: explicit > yaml > env > defaults const envConfig = resolveConfigFromEnv(); const yamlConfig = loadYamlConfig() ?? {}; // Merge configs: explicit config > yaml file > env vars > defaults const mergedConfig: AutotelConfig = { ...envConfig, // Environment variables (lowest priority) ...yamlConfig, // YAML file (middle priority) ...cfg, // Explicit config (highest priority) // Deep merge for resourceAttributes resourceAttributes: { ...envConfig.resourceAttributes, ...yamlConfig.resourceAttributes, ...detectEnvironmentAttributes(), ...cfg.resourceAttributes, }, // Handle headers merge (can be string or object) headers: cfg.headers ?? yamlConfig.headers ?? envConfig.headers, } as AutotelConfig; if (mergedConfig.attributeRedactor !== undefined) { const normalizedRedactor = normalizeAttributeRedactorConfig( mergedConfig.attributeRedactor, ); if (!normalizedRedactor) { throw new Error('Invalid attributeRedactor config'); } mergedConfig.attributeRedactor = normalizedRedactor; } const devtoolsConfig = resolveDevtoolsConfig(mergedConfig.devtools); if (devtoolsConfig.enabled && mergedConfig.logs === undefined) { mergedConfig.logs = true; } const silent = mergedConfig.silent ?? false; const minLevel = mergedConfig.minLevel ?? 'info'; const baseLogger = mergedConfig.logger || silentLogger; logger = wrapLogger(baseLogger, silent, minLevel); // Warn if re-initializing (same behavior in all environments) if (initialized) { logger.warn( {}, '[autotel] init() called again - last config wins. This may cause unexpected behavior.', ); } config = mergedConfig; validationConfig = mergedConfig.validation || null; eventsConfig = mergedConfig.events || null; // Initialize OpenTelemetry // Only use endpoint if explicitly configured (no default fallback) let endpoint = mergedConfig.endpoint ?? devtoolsConfig.endpoint; const otlpHeaders = normalizeOtlpHeaders(mergedConfig.headers); const version = mergedConfig.version || detectVersion(); const environment = mergedConfig.environment || process.env.NODE_ENV || 'development'; const metricsEnabled = resolveMetricsFlag(mergedConfig.metrics); const logsEnabled = resolveLogsFlag(mergedConfig.logs); if (devtoolsConfig.enabled && devtoolsConfig.embedded) { const devtoolsModule = _optionalRequire<{ createDevtools?: (options?: { port?: number; host?: string; verbose?: boolean; }) => { port: number; close: () => Promise<void> | void }; }>('autotel-devtools'); if (devtoolsModule?.createDevtools) { const devtoolsInstance = devtoolsModule.createDevtools({ port: devtoolsConfig.port, host: devtoolsConfig.host, verbose: devtoolsConfig.verbose, }); _devtoolsClose = devtoolsInstance.close; endpoint = `http://${devtoolsConfig.host}:${devtoolsInstance.port}`; logger.info( {}, `[autotel] autotel-devtools embedded server started at ${endpoint}`, ); } else { logger.warn( {}, '[autotel] devtools.embedded requested but autotel-devtools is not installed. Falling back to endpoint-only mode.', ); } } // Detect hostname for proper Datadog correlation and Service Catalog discovery const hostname = detectHostname(); let resource = resourceFromAttributes({ [ATTR_SERVICE_NAME]: mergedConfig.service, [ATTR_SERVICE_VERSION]: version, // Support both old and new OpenTelemetry semantic conventions for environment 'deployment.environment': environment, // Deprecated but widely supported 'deployment.environment.name': environment, // OTel v1.27.0+ standard }); // Add hostname attributes for Datadog Service Catalog and infrastructure correlation if (hostname) { resource = resource.merge( resourceFromAttributes({ 'host.name': hostname, // OpenTelemetry standard 'datadog.host.name': hostname, // Datadog-specific, highest priority for Datadog }), ); } if (mergedConfig.resource) { resource = resource.merge(mergedConfig.resource); } if (mergedConfig.resourceAttributes) { resource = resource.merge( resourceFromAttributes(mergedConfig.resourceAttributes), ); } // Resolve OTLP protocol (http or grpc) const protocol = resolveProtocol(mergedConfig.protocol); // Build array of span processors (supports multiple) let spanProcessors: SpanProcessor[] = []; if (mergedConfig.spanProcessors && mergedConfig.spanProcessors.length > 0) { // User provided custom processors (full control) spanProcessors.push(...mergedConfig.spanProcessors); } else if ( mergedConfig.spanExporters && mergedConfig.spanExporters.length > 0 ) { // User provided custom exporters (wrap each with tail sampling) for (const exporter of mergedConfig.spanExporters) { spanProcessors.push( new TailSamplingSpanProcessor(new BatchSpanProcessor(exporter)), ); } } else if (endpoint) { // Default: OTLP with tail sampling (only if endpoint is configured) const traceExporter = createTraceExporter(protocol, { url: formatEndpointUrl(endpoint, 'traces', protocol), headers: otlpHeaders, }); spanProcessors.push( new TailSamplingSpanProcessor(new BatchSpanProcessor(traceExporter)), ); } // If no endpoint and no custom processors/exporters, array remains empty // SDK will still work but won't export traces // Add baggage span processor if enabled if (mergedConfig.baggage) { const prefix = typeof mergedConfig.baggage === 'string' ? mergedConfig.baggage ? `${mergedConfig.baggage}.` : '' : 'baggage.'; spanProcessors.push(new BaggageSpanProcessor({ prefix })); } // Apply debug mode configuration const debugMode = resolveDebugFlag(mergedConfig.debug); if (debugMode === 'pretty') { // Pretty debug: colorized, hierarchical output spanProcessors.push(new SimpleSpanProcessor(new PrettyConsoleExporter())); } else if (debugMode === true) { // Raw debug: JSON output spanProcessors.push(new SimpleSpanProcessor(new ConsoleSpanExporter())); } // Add canonical log line processor BEFORE wrapping processors // This ensures it gets wrapped with the same filter/normalizer/redactor as other processors, // so canonical logs respect spanFilter (filtered spans aren't logged), spanNameNormalizer // (normalized names are used), and attributeRedactor (sensitive data is redacted). if (mergedConfig.canonicalLogLines?.enabled) { const canonicalOptions: CanonicalLogLineOptions = { logger: mergedConfig.canonicalLogLines.logger || mergedConfig.logger, rootSpansOnly: mergedConfig.canonicalLogLines.rootSpansOnly, minLevel: mergedConfig.canonicalLogLines.minLevel, messageFormat: mergedConfig.canonicalLogLines.messageFormat, includeResourceAttributes: mergedConfig.canonicalLogLines.includeResourceAttributes, shouldEmit: mergedConfig.canonicalLogLines.shouldEmit, keep: mergedConfig.canonicalLogLines.keep, drain: mergedConfig.canonicalLogLines.drain, onDrainError: mergedConfig.canonicalLogLines.onDrainError, pretty: mergedConfig.canonicalLogLines.pretty, }; spanProcessors.push(new CanonicalLogLineProcessor(canonicalOptions)); } // Wrap processors in order: redactor (innermost) → normalizer → filter (outermost) // This ensures onEnd() execution order is: filter → normalizer → redactor // So filtering sees original attributes, and redaction happens last before export. // Step 1: Wrap with AttributeRedactingProcessor (innermost - executes last in onEnd) if (mergedConfig.attributeRedactor && spanProcessors.length > 0) { spanProcessors = spanProcessors.map( (processor) => new AttributeRedactingProcessor(processor, { redactor: mergedConfig.attributeRedactor!, }), ); } // Store string redactor for use by PostHog log/subscriber paths if (mergedConfig.attributeRedactor) { _stringRedactor = createStringRedactor(mergedConfig.attributeRedactor); } // Wire string redactor to subscribers that support it (e.g., PostHogSubscriber) if (_stringRedactor && mergedConfig.subscribers) { for (const subscriber of mergedConfig.subscribers) { if ( 'setStringRedactor' in subscriber &&