autotel
Version:
Write Once, Observe Anywhere
1,622 lines (1,511 loc) • 68.2 kB
text/typescript
/**
* 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 &&