autotel
Version:
Write Once, Observe Anywhere
365 lines (339 loc) • 11.5 kB
text/typescript
/**
* Zero-dependency structured logger for autotel
*
* This logger provides:
* - Structured JSON logging (production) or pretty print (development)
* - Auto trace context injection (traceId, spanId, correlationId)
* - Dynamic log level control (per-request via OTel context)
* - Level support (debug, info, warn, error, none)
* - Zero additional dependencies (uses @opentelemetry/api, already a dep)
*
* Uses Pino-compatible signature supporting both patterns:
* - `log.info('simple message')` - string-only
* - `log.info({ userId: '123' }, 'message')` - object first with optional message
*
* Used as the default fallback when users don't provide Pino/Bunyan.
* Can also be used directly: import { createBuiltinLogger } from 'autotel/logger'
*
* @example
* ```typescript
* import { createBuiltinLogger, runWithLogLevel } from 'autotel/logger';
*
* const log = createBuiltinLogger('my-service');
*
* // Simple message (no metadata)
* log.info('Server started');
*
* // With metadata (Pino-style: object first, message second)
* log.info({ userId: '123' }, 'User created');
* // Output: {"level":"info","service":"my-service","msg":"User created","userId":"123","traceId":"..."}
*
* // Dynamic log level per-request
* runWithLogLevel('debug', () => {
* log.debug('This will log even if default level is "info"');
* });
* ```
*/
import {
trace,
context as api_context,
createContextKey,
} from '@opentelemetry/api';
import type { Logger } from './logger';
export type BuiltinLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'none';
/**
* Context key for storing active log level (enables per-request log levels)
*/
const LOG_LEVEL_KEY = createContextKey('autotel-log-level');
/**
* Get the active log level from context (if set)
* Falls back to undefined if no log level is set in context
*/
export function getActiveLogLevel(): BuiltinLogLevel | undefined {
return api_context.active().getValue(LOG_LEVEL_KEY) as
| BuiltinLogLevel
| undefined;
}
/**
* Run a function with a specific log level
* The log level is stored in OpenTelemetry context and applies to all logger calls within the callback
*
* @example
* ```typescript
* // Enable debug logging for a specific request
* runWithLogLevel('debug', () => {
* log.debug('This will be logged');
* processRequest();
* });
*
* // Disable logging temporarily
* runWithLogLevel('none', () => {
* log.info('This will NOT be logged');
* });
* ```
*/
export function runWithLogLevel<T>(
level: BuiltinLogLevel,
callback: () => T,
): T {
const ctx = api_context.active().setValue(LOG_LEVEL_KEY, level);
return api_context.with(ctx, callback);
}
/**
* Get current trace context from active span
* Returns null if no active span exists
*/
function getTraceContextInternal(): {
traceId: string;
spanId: string;
correlationId: string;
} | null {
const span = trace.getActiveSpan();
if (!span) return null;
const ctx = span.spanContext();
return {
traceId: ctx.traceId,
spanId: ctx.spanId,
correlationId: ctx.traceId.slice(0, 16), // First 16 chars for grouping
};
}
/**
* Helper to get trace context (useful for BYOL - Bring Your Own Logger)
*
* @example
* ```typescript
* import bunyan from 'bunyan';
* import { getTraceContext } from 'autotel/logger';
*
* const bunyanLogger = bunyan.createLogger({ name: 'myapp' });
* const ctx = getTraceContext();
* bunyanLogger.info({ ...ctx, email: 'test@example.com' }, 'Creating user');
* ```
*/
export function getTraceContext() {
return getTraceContextInternal();
}
export interface BuiltinLoggerOptions {
/** Minimum log level. Default: 'info' */
level?: BuiltinLogLevel;
/** Pretty print for development. Default: false (JSON output) */
pretty?: boolean;
}
/**
* Create a lightweight structured logger
*
* @param service - Service name for logging
* @param options - Optional configuration
*
* @example
* ```typescript
* const log = createBuiltinLogger('user-service');
*
* log.info('Creating user', { email: 'test@example.com' });
* // Output: {"level":"info","service":"user-service","msg":"Creating user",
* // "email":"test@example.com","traceId":"...","spanId":"..."}
*
* // Dynamic log level control per-request
* runWithLogLevel('debug', () => {
* log.debug('This will be logged even if logger was created with level: "info"');
* });
* ```
*/
export function createBuiltinLogger(
service: string,
options?: BuiltinLoggerOptions,
): Logger {
const defaultLevel = options?.level || 'info';
const pretty = options?.pretty || false;
const levelPriority: Record<BuiltinLogLevel, number> = {
none: -1,
debug: 0,
info: 1,
warn: 2,
error: 3,
};
const shouldLog = (level: BuiltinLogLevel): boolean => {
// Priority: context level > options level > 'info' default
const activeLevel = getActiveLogLevel() ?? defaultLevel;
// 'none' means suppress all logging
if (activeLevel === 'none') return false;
return levelPriority[level] >= levelPriority[activeLevel];
};
const log = (
level: 'info' | 'error' | 'warn' | 'debug',
msg: string,
attrs?: Record<string, unknown>,
) => {
if (!shouldLog(level)) return;
const ctx = getTraceContextInternal();
const logEntry: Record<string, unknown> = {
level,
service,
msg,
...attrs,
...ctx, // Auto-inject traceId, spanId, correlationId
timestamp: new Date().toISOString(),
};
if (pretty) {
// Pretty print for development
const traceInfo = ctx
? ` [${ctx.traceId.slice(0, 8)}.../${ctx.spanId.slice(0, 8)}...]`
: '';
console.log(
`[${level.toUpperCase()}]${traceInfo} ${service}: ${msg}`,
attrs || '',
);
} else {
// Structured JSON for production
console.log(JSON.stringify(logEntry));
}
};
// Pino-compatible signature: supports both:
// - logger.info('message') - string-only
// - logger.info({ extra }, 'message') - Pino style with metadata
// Also auto-detects and handles legacy Winston-style: logger.info('message', { extra })
const createLogMethod = (level: 'info' | 'warn' | 'debug') => {
return (
extraOrMessage: Record<string, unknown> | string,
message?: string | Record<string, unknown>,
) => {
if (typeof extraOrMessage === 'string') {
// First arg is string - could be:
// 1. String-only: logger.info('message')
// 2. Legacy Winston-style: logger.info('message', { extra })
if (message !== undefined && typeof message === 'object') {
// Legacy Winston-style detected - auto-swap for backward compatibility
if (process.env.NODE_ENV !== 'production') {
console.warn(
`[autotel] Legacy logger pattern detected: logger.${level}('message', metadata). ` +
`Autotel recommends Pino signature: logger.${level}({ ...metadata }, 'message'). ` +
`Auto-swapping arguments for compatibility.`,
);
}
// Swap: treat first arg as message, second as metadata
log(level, extraOrMessage, message as Record<string, unknown>);
} else {
// Pure string-only call: logger.info('message')
log(level, extraOrMessage);
}
} else {
// Pino style: logger.info({ extra }, 'message')
log(level, (message as string) || '', extraOrMessage);
}
};
};
return {
info: createLogMethod('info'),
warn: createLogMethod('warn'),
debug: createLogMethod('debug'),
error: (
extraOrMessage: Record<string, unknown> | string,
message?: string | Record<string, unknown> | Error,
) => {
if (typeof extraOrMessage === 'string') {
// First arg is string - could be:
// 1. String-only: logger.error('message')
// 2. Legacy: logger.error('message', error) - Error as second arg
// 3. Legacy: logger.error('message', { extra }) - object as second arg
// Handle legacy logger.error('message', error) pattern
if (message instanceof Error) {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`[autotel] Legacy logger pattern detected: logger.error('message', error). ` +
`Autotel recommends Pino signature: logger.error({ err: error }, 'message'). ` +
`Auto-swapping arguments for compatibility.`,
);
}
log('error', extraOrMessage, {
error: message.message,
stack: message.stack,
name: message.name,
});
return;
}
// Handle legacy logger.error('message', { extra }) pattern
if (message !== undefined && typeof message === 'object') {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`[autotel] Legacy logger pattern detected: logger.error('message', metadata). ` +
`Autotel recommends Pino signature: logger.error({ ...metadata }, 'message'). ` +
`Auto-swapping arguments for compatibility.`,
);
}
// Swap: treat first arg as message, second as metadata (handle err extraction)
const extra = message as Record<string, unknown>;
const { err, ...rest } = extra as Record<string, unknown> & {
err?: unknown;
};
let errorAttrs: Record<string, unknown> = rest;
if (err instanceof Error) {
errorAttrs = {
error: err.message,
stack: err.stack,
name: err.name,
...rest,
};
} else if (err !== undefined) {
errorAttrs = { err, ...rest };
}
log('error', extraOrMessage, errorAttrs);
return;
}
// Pure string-only call: logger.error('message')
log('error', extraOrMessage);
return;
}
// Pino style: logger.error({ err, ...extra }, 'message')
// Extract err from extra if present (Pino convention)
const { err, ...rest } = extraOrMessage as Record<string, unknown> & {
err?: unknown;
};
let errorAttrs: Record<string, unknown> = rest;
if (err instanceof Error) {
// err is an Error - extract message, stack, name for structured logging
errorAttrs = {
error: err.message,
stack: err.stack,
name: err.name,
...rest,
};
} else if (err !== undefined) {
// err is not an Error but exists - preserve it as-is
errorAttrs = {
err,
...rest,
};
}
log('error', (message as string) || '', errorAttrs);
},
};
}
/**
* Pino-like factory function for creating an autotel logger
*
* @example
* ```typescript
* import { autotelLogger } from 'autotel/logger';
*
* const log = autotelLogger();
*
* // Simple message
* log.info('Server started');
*
* // With metadata (Pino-style: object first, message second)
* log.info({ userId: '123' }, 'User created');
*
* // With options
* const devLog = autotelLogger({ service: 'my-app', level: 'debug', pretty: true });
* ```
*/
export function autotelLogger(options?: {
service?: string;
level?: BuiltinLogLevel;
pretty?: boolean;
}): Logger {
return createBuiltinLogger(options?.service || 'app', {
level: options?.level,
pretty: options?.pretty,
});
}