autotel
Version:
Write Once, Observe Anywhere
397 lines (362 loc) • 12.4 kB
text/typescript
/**
* Canonical Log Line Processor
*
* Automatically emits spans as canonical log lines (wide events) when they end.
* Implements canonical log line" pattern: one comprehensive
* event per request with all context.
*
* When a span ends, this processor creates a log record with ALL span attributes,
* making the span itself the canonical log line that can be queried like structured data.
*
* @example
* ```typescript
* import { init } from 'autotel';
*
* init({
* service: 'my-app',
* canonicalLogLines: {
* enabled: true,
* rootSpansOnly: true, // One canonical log line per request
* },
* });
* ```
*/
import type {
SpanProcessor,
ReadableSpan,
} from '@opentelemetry/sdk-trace-base';
import type { Attributes, AttributeValue } from '@opentelemetry/api';
import { logs, SeverityNumber } from '@opentelemetry/api-logs';
import type { Logger } from '../logger';
import { formatPrettyLogLine, formatDuration } from '../pretty-log-formatter';
/**
* Function to redact sensitive attribute values
*/
export type AttributeRedactorFn = (
key: string,
value: AttributeValue,
) => AttributeValue;
export interface CanonicalLogLineEvent {
span: ReadableSpan;
level: 'debug' | 'info' | 'warn' | 'error';
message: string;
event: Record<string, unknown>;
}
export interface KeepCondition {
/** Keep events where HTTP status >= this value. */
status?: number;
/** Keep events where duration_ms >= this value. */
durationMs?: number;
/** Keep events matching this path pattern (simple prefix match). */
path?: string;
}
export interface CanonicalLogLineOptions {
/** 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: ReadableSpan) => string;
/** Whether to include resource attributes (default: true) */
includeResourceAttributes?: boolean;
/**
* Attribute redactor function to apply before logging.
* This ensures sensitive data is redacted in canonical log lines,
* matching the behavior of attributeRedactor in init().
*/
attributeRedactor?: AttributeRedactorFn;
/** Predicate to decide whether to emit (runs after event is built). */
shouldEmit?: (ctx: CanonicalLogLineEvent) => boolean;
/**
* Declarative tail sampling conditions (OR logic). If any condition matches,
* the event is kept. Ignored when `shouldEmit` is provided.
*
* @example
* keep: [{ status: 500 }, { durationMs: 1000 }]
*/
keep?: KeepCondition[];
/** Callback invoked after emit for custom fan-out. */
drain?: (ctx: CanonicalLogLineEvent) => void | Promise<void>;
/** Handler for drain failures. */
onDrainError?: (error: unknown, ctx: CanonicalLogLineEvent) => void;
/**
* Pretty-print canonical log lines to console in a tree format.
* Defaults to true when NODE_ENV is 'development'.
*/
pretty?: boolean;
}
/**
* Span processor that automatically emits spans as canonical log lines
*
* When a span ends, this processor creates a log record with ALL span attributes.
* This implements the "canonical log line" pattern: one comprehensive event
* per request with all context, queryable as structured data.
*
* **Key Benefits:**
* - One log line per request with all context (wide event)
* - High-cardinality, high-dimensionality data for powerful queries
* - Automatic - no manual logging needed
* - Works with any logger or OTel Logs API
*
* @example Basic usage
* ```typescript
* import { init } from 'autotel';
*
* init({
* service: 'checkout-api',
* canonicalLogLines: {
* enabled: true,
* rootSpansOnly: true, // One canonical log line per request
* },
* });
* ```
*
* @example With custom logger
* ```typescript
* import pino from 'pino';
* import { init } from 'autotel';
*
* 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}]`;
* },
* },
* });
* ```
*/
export class CanonicalLogLineProcessor implements SpanProcessor {
private logger?: Logger;
private rootSpansOnly: boolean;
private minLevel: 'debug' | 'info' | 'warn' | 'error';
private messageFormat: (span: ReadableSpan) => string;
private includeResourceAttributes: boolean;
private attributeRedactor?: AttributeRedactorFn;
private shouldEmit?: (ctx: CanonicalLogLineEvent) => boolean;
private drain?: (ctx: CanonicalLogLineEvent) => void | Promise<void>;
private onDrainError?: (error: unknown, ctx: CanonicalLogLineEvent) => void;
private pretty: boolean;
private getOTelLogger: (() => ReturnType<typeof logs.getLogger>) | null =
null;
constructor(options: CanonicalLogLineOptions = {}) {
this.logger = options.logger;
this.rootSpansOnly = options.rootSpansOnly ?? false;
this.minLevel = options.minLevel ?? 'info';
this.messageFormat =
options.messageFormat ?? ((span) => `[${span.name}] Request completed`);
this.includeResourceAttributes = options.includeResourceAttributes ?? true;
this.attributeRedactor = options.attributeRedactor;
this.shouldEmit =
options.shouldEmit ?? this.buildKeepPredicate(options.keep);
this.drain = options.drain;
this.onDrainError = options.onDrainError;
this.pretty =
options.pretty ??
(typeof process !== 'undefined' &&
process.env.NODE_ENV === 'development');
if (!this.logger) {
this.getOTelLogger = () => logs.getLogger('autotel.canonical-log-line');
}
}
private buildKeepPredicate(
keep?: KeepCondition[],
): ((ctx: CanonicalLogLineEvent) => boolean) | undefined {
if (!keep || keep.length === 0) return undefined;
return (ctx: CanonicalLogLineEvent) => {
return keep.some((condition) => {
if (condition.status !== undefined) {
const httpStatus = Number(
ctx.event['http.response.status_code'] ?? 0,
);
if (httpStatus >= condition.status) return true;
}
if (
condition.durationMs !== undefined &&
Number(ctx.event.duration_ms ?? 0) >= condition.durationMs
) {
return true;
}
if (condition.path !== undefined) {
const route = String(
ctx.event['http.route'] ?? ctx.event['url.path'] ?? '',
);
if (route.startsWith(condition.path)) return true;
}
return false;
});
};
}
onStart(): void {
// No-op
}
onEnd(span: ReadableSpan): void {
if (
this.rootSpansOnly &&
span.parentSpanContext?.spanId &&
!span.parentSpanContext.isRemote
) {
return;
}
const level = this.getLogLevel(span);
if (!this.shouldLog(level)) {
return;
}
const canonicalLogLine = this.buildCanonicalLogLine(span);
const message = this.messageFormat(span);
const eventContext: CanonicalLogLineEvent = {
span,
level,
message,
event: canonicalLogLine,
};
if (this.shouldEmit && !this.shouldEmit(eventContext)) return;
if (this.pretty) {
console.log(formatPrettyLogLine(eventContext));
}
if (this.logger) {
this.emitViaLogger(level, message, canonicalLogLine);
} else if (this.getOTelLogger) {
const otelLogger = this.getOTelLogger();
this.emitViaOTel(level, message, canonicalLogLine, otelLogger);
}
if (this.drain) {
Promise.resolve(this.drain(eventContext)).catch((error) => {
if (this.onDrainError) {
this.onDrainError(error, eventContext);
return;
}
this.reportInternalWarning('canonicalLogLines.drain failed', error);
});
}
}
private buildCanonicalLogLine(span: ReadableSpan): Record<string, unknown> {
const durationMs = span.duration[0] * 1000 + span.duration[1] / 1_000_000;
const timestamp = new Date(
span.startTime[0] * 1000 + span.startTime[1] / 1_000_000,
).toISOString();
// Span attributes first so core metadata fields below take precedence
const canonicalLogLine: Record<string, unknown> = {};
const attributes = this.redactAttributes(span.attributes);
Object.assign(canonicalLogLine, attributes);
if (this.includeResourceAttributes) {
const resourceAttrs = this.redactAttributes(
span.resource.attributes as Attributes,
);
Object.assign(canonicalLogLine, resourceAttrs);
}
canonicalLogLine.operation = span.name;
canonicalLogLine.traceId = span.spanContext().traceId;
canonicalLogLine.spanId = span.spanContext().spanId;
canonicalLogLine.correlationId = span.spanContext().traceId.slice(0, 16);
canonicalLogLine.duration_ms = Math.round(durationMs * 100) / 100;
canonicalLogLine.duration = formatDuration(durationMs);
canonicalLogLine.status_code = span.status.code;
canonicalLogLine.status_message = span.status.message || undefined;
canonicalLogLine.timestamp = timestamp;
return canonicalLogLine;
}
private redactAttributes(attributes: Attributes): Record<string, unknown> {
if (!this.attributeRedactor) {
return { ...attributes };
}
const redacted: Record<string, unknown> = {};
for (const [key, value] of Object.entries(attributes)) {
if (value !== undefined) {
redacted[key] = this.attributeRedactor(key, value);
}
}
return redacted;
}
private emitViaLogger(
level: 'debug' | 'info' | 'warn' | 'error',
message: string,
canonicalLogLine: Record<string, unknown>,
): void {
this.logger;
}
private emitViaOTel(
level: 'debug' | 'info' | 'warn' | 'error',
message: string,
canonicalLogLine: Record<string, unknown>,
otelLogger: ReturnType<typeof logs.getLogger>,
): void {
const otelAttributes: Record<string, string | number | boolean> = {};
for (const [key, value] of Object.entries(canonicalLogLine)) {
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
otelAttributes[key] = value;
} else if (value !== null && value !== undefined) {
otelAttributes[key] = String(value);
}
}
otelLogger.emit({
severityNumber: this.getSeverityNumber(level),
severityText: level.toUpperCase(),
body: message,
attributes: otelAttributes,
});
}
private getLogLevel(span: ReadableSpan): 'debug' | 'info' | 'warn' | 'error' {
const explicitLevel = span.attributes['autotel.log.level'];
if (
explicitLevel === 'debug' ||
explicitLevel === 'info' ||
explicitLevel === 'warn' ||
explicitLevel === 'error'
) {
return explicitLevel;
}
if (span.status.code === 2) return 'error';
return 'info';
}
private shouldLog(level: string): boolean {
const levels = ['debug', 'info', 'warn', 'error'];
return levels.indexOf(level) >= levels.indexOf(this.minLevel);
}
private getSeverityNumber(level: string): SeverityNumber {
const mapping: Record<string, SeverityNumber> = {
debug: SeverityNumber.DEBUG,
info: SeverityNumber.INFO,
warn: SeverityNumber.WARN,
error: SeverityNumber.ERROR,
};
return mapping[level] ?? SeverityNumber.INFO;
}
private reportInternalWarning(message: string, error: unknown): void {
const err =
error instanceof Error ? error.message : String(error ?? 'unknown error');
if (this.logger) {
this.logger.warn({ error: err }, `[autotel] ${message}`);
return;
}
console.warn(`[autotel] ${message}: ${err}`);
}
async forceFlush(): Promise<void> {
// No-op
}
async shutdown(): Promise<void> {
// No-op
}
}