autotel
Version:
Write Once, Observe Anywhere
377 lines (374 loc) • 12.5 kB
JavaScript
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