UNPKG

autotel

Version:
377 lines (374 loc) 12.5 kB
let _opentelemetry_api_logs = require("@opentelemetry/api-logs"); //#region src/pretty-log-formatter.ts const RESET = "\x1B[0m"; const DIM = "\x1B[2m"; const BOLD = "\x1B[1m"; const RED = "\x1B[31m"; const YELLOW = "\x1B[33m"; const GREEN = "\x1B[32m"; const CYAN = "\x1B[36m"; const LEVEL_COLORS = { debug: "\x1B[90m", info: GREEN, warn: YELLOW, error: RED }; /** Internal OTel attributes to skip in pretty output. */ const SKIP_PREFIXES = [ "telemetry.", "otel.", "process.", "os.", "host.", "service.", "autotel." ]; const SKIP_KEYS = new Set([ "operation", "traceId", "spanId", "correlationId", "duration_ms", "duration", "status_code", "status_message", "timestamp", "http.request.method", "url.path", "http.route", "http.response.status_code" ]); function useColor() { if (typeof process !== "undefined") { if (process.env.NO_COLOR) return false; if (process.env.FORCE_COLOR) return true; if (process.stdout?.isTTY) return true; } return false; } function c(color, text) { return useColor() ? `${color}${text}${RESET}` : text; } /** * Format milliseconds into a human-readable duration string. * * @example * formatDuration(45) // "45ms" * formatDuration(1234) // "1.2s" * formatDuration(65000) // "1m 5s" */ function formatDuration(ms) { if (ms < 1e3) return `${Math.round(ms)}ms`; if (ms < 6e4) { const seconds = ms / 1e3; return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`; } const minutes = Math.floor(ms / 6e4); const seconds = Math.round(ms % 6e4 / 1e3); return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; } function formatTime(iso) { try { return new Date(iso).toLocaleTimeString("en-GB", { hour12: false }); } catch { return iso.slice(11, 19); } } function formatValue(value) { if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); if (value == null) return ""; try { return JSON.stringify(value); } catch { return String(value); } } /** * Group flat dot-notation attributes into a nested tree for pretty display. * e.g. { 'user.id': '1', 'user.plan': 'pro' } → { user: { id: '1', plan: 'pro' } } */ function groupAttributes(event) { const tree = {}; for (const [key, value] of Object.entries(event)) { if (SKIP_KEYS.has(key)) continue; if (SKIP_PREFIXES.some((p) => key.startsWith(p))) continue; if (value == null || value === "") continue; const parts = key.split("."); if (parts.length === 1) tree[key] = value; else { let current = tree; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (!(part in current) || typeof current[part] !== "object") current[part] = {}; current = current[part]; } current[parts[parts.length - 1]] = value; } } return tree; } function renderTree(obj, indent, isLast) { const lines = []; const entries = Object.entries(obj); entries.forEach(([key, value], idx) => { const last = idx === entries.length - 1; const prefix = indent + (last ? "└─" : "├─") + " "; if (value && typeof value === "object" && !Array.isArray(value)) { const nested = value; const flatValues = Object.entries(nested).filter(([, v]) => typeof v !== "object" || v === null); if (Object.entries(nested).filter(([, v]) => typeof v === "object" && v !== null && !Array.isArray(v)).length === 0) { const inline = flatValues.map(([k, v]) => `${c(CYAN, k)}=${formatValue(v)}`).join(" "); lines.push(`${prefix}${c(BOLD, key)}: ${inline}`); } else { lines.push(`${prefix}${c(BOLD, key)}:`); const nextIndent = indent + (last ? " " : "│ "); lines.push(...renderTree(nested, nextIndent, [...isLast, last])); } } else lines.push(`${prefix}${c(CYAN, key)}: ${c(DIM, formatValue(value))}`); }); return lines; } /** * Format a canonical log line event as a pretty tree for development output. */ function formatPrettyLogLine(ctx) { const { event, level, message } = ctx; const timestamp = formatTime(String(event.timestamp ?? "")); const service = event["service.name"] || event.service || ""; const method = event["http.request.method"] || ""; const path = event["http.route"] || event["url.path"] || ""; const status = event["http.response.status_code"] || event.status_code || ""; const duration = formatDuration(Number(event.duration_ms ?? 0)); const levelStr = c(LEVEL_COLORS[level] ?? "", level.toUpperCase().padEnd(5)); const parts = [c(DIM, timestamp), levelStr]; if (service) parts.push(c(DIM, `[${service}]`)); if (method) parts.push(c(BOLD, String(method))); if (path) parts.push(String(path)); if (status) { const statusNum = Number(status); const statusColor = statusNum >= 500 ? RED : statusNum >= 400 ? YELLOW : GREEN; parts.push(c(statusColor, String(status))); } parts.push(c(DIM, `in ${duration}`)); const header = parts.join(" "); const tree = groupAttributes(event); if (Object.keys(tree).length === 0) return header; return [header, ...renderTree(tree, " ", [])].join("\n"); } //#endregion //#region src/processors/canonical-log-line-processor.ts /** * 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}]`; * }, * }, * }); * ``` */ var CanonicalLogLineProcessor = class { logger; rootSpansOnly; minLevel; messageFormat; includeResourceAttributes; attributeRedactor; shouldEmit; drain; onDrainError; pretty; getOTelLogger = null; constructor(options = {}) { 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 = () => _opentelemetry_api_logs.logs.getLogger("autotel.canonical-log-line"); } buildKeepPredicate(keep) { if (!keep || keep.length === 0) return void 0; return (ctx) => { return keep.some((condition) => { if (condition.status !== void 0) { if (Number(ctx.event["http.response.status_code"] ?? 0) >= condition.status) return true; } if (condition.durationMs !== void 0 && Number(ctx.event.duration_ms ?? 0) >= condition.durationMs) return true; if (condition.path !== void 0) { if (String(ctx.event["http.route"] ?? ctx.event["url.path"] ?? "").startsWith(condition.path)) return true; } return false; }); }; } onStart() {} onEnd(span) { 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 = { 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); }); } buildCanonicalLogLine(span) { const durationMs = span.duration[0] * 1e3 + span.duration[1] / 1e6; const timestamp = (/* @__PURE__ */ new Date(span.startTime[0] * 1e3 + span.startTime[1] / 1e6)).toISOString(); const canonicalLogLine = {}; const attributes = this.redactAttributes(span.attributes); Object.assign(canonicalLogLine, attributes); if (this.includeResourceAttributes) { const resourceAttrs = this.redactAttributes(span.resource.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 || void 0; canonicalLogLine.timestamp = timestamp; return canonicalLogLine; } redactAttributes(attributes) { if (!this.attributeRedactor) return { ...attributes }; const redacted = {}; for (const [key, value] of Object.entries(attributes)) if (value !== void 0) redacted[key] = this.attributeRedactor(key, value); return redacted; } emitViaLogger(level, message, canonicalLogLine) { this.logger[level](canonicalLogLine, message); } emitViaOTel(level, message, canonicalLogLine, otelLogger) { const otelAttributes = {}; 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 !== void 0) otelAttributes[key] = String(value); otelLogger.emit({ severityNumber: this.getSeverityNumber(level), severityText: level.toUpperCase(), body: message, attributes: otelAttributes }); } getLogLevel(span) { 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"; } shouldLog(level) { const levels = [ "debug", "info", "warn", "error" ]; return levels.indexOf(level) >= levels.indexOf(this.minLevel); } getSeverityNumber(level) { return { debug: _opentelemetry_api_logs.SeverityNumber.DEBUG, info: _opentelemetry_api_logs.SeverityNumber.INFO, warn: _opentelemetry_api_logs.SeverityNumber.WARN, error: _opentelemetry_api_logs.SeverityNumber.ERROR }[level] ?? _opentelemetry_api_logs.SeverityNumber.INFO; } reportInternalWarning(message, error) { 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() {} async shutdown() {} }; //#endregion Object.defineProperty(exports, 'CanonicalLogLineProcessor', { enumerable: true, get: function () { return CanonicalLogLineProcessor; } }); Object.defineProperty(exports, 'formatDuration', { enumerable: true, get: function () { return formatDuration; } }); //# sourceMappingURL=canonical-log-line-processor--RlFDHhm.cjs.map