Files
elasticsearch/ts/core/observability/tracing.ts

439 lines
9.8 KiB
TypeScript

/**
* Span attributes
*/
export type SpanAttributes = Record<string, string | number | boolean | null | undefined>;
/**
* Span status
*/
export enum SpanStatus {
OK = 'OK',
ERROR = 'ERROR',
UNSET = 'UNSET',
}
/**
* Span context for distributed tracing
*/
export interface SpanContext {
traceId: string;
spanId: string;
traceFlags: number;
traceState?: string;
}
/**
* Span interface
*/
export interface Span {
/** Span name */
name: string;
/** Span start time */
startTime: Date;
/** Span end time (if ended) */
endTime?: Date;
/** Span status */
status: SpanStatus;
/** Span attributes */
attributes: SpanAttributes;
/** Span context */
context: SpanContext;
/** Parent span ID */
parentSpanId?: string;
/** Set an attribute */
setAttribute(key: string, value: string | number | boolean): void;
/** Set multiple attributes */
setAttributes(attributes: SpanAttributes): void;
/** Set span status */
setStatus(status: SpanStatus, message?: string): void;
/** Add an event to the span */
addEvent(name: string, attributes?: SpanAttributes): void;
/** End the span */
end(): void;
/** Record an exception */
recordException(exception: Error): void;
}
/**
* Tracer interface
*/
export interface Tracer {
/** Start a new span */
startSpan(name: string, attributes?: SpanAttributes): Span;
/** Get the active span */
getActiveSpan(): Span | null;
/** Execute function within span context */
withSpan<T>(name: string, fn: (span: Span) => Promise<T>): Promise<T>;
}
/**
* Generate a random trace ID
*/
function generateTraceId(): string {
return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
}
/**
* Generate a random span ID
*/
function generateSpanId(): string {
return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
}
/**
* Basic in-memory span implementation
*/
export class InMemorySpan implements Span {
public name: string;
public startTime: Date;
public endTime?: Date;
public status: SpanStatus = SpanStatus.UNSET;
public attributes: SpanAttributes;
public context: SpanContext;
public parentSpanId?: string;
public events: Array<{ name: string; timestamp: Date; attributes?: SpanAttributes }> = [];
constructor(
name: string,
attributes: SpanAttributes = {},
parentContext?: SpanContext
) {
this.name = name;
this.startTime = new Date();
this.attributes = { ...attributes };
if (parentContext) {
this.context = {
traceId: parentContext.traceId,
spanId: generateSpanId(),
traceFlags: parentContext.traceFlags,
traceState: parentContext.traceState,
};
this.parentSpanId = parentContext.spanId;
} else {
this.context = {
traceId: generateTraceId(),
spanId: generateSpanId(),
traceFlags: 1,
};
}
}
setAttribute(key: string, value: string | number | boolean): void {
this.attributes[key] = value;
}
setAttributes(attributes: SpanAttributes): void {
Object.assign(this.attributes, attributes);
}
setStatus(status: SpanStatus, message?: string): void {
this.status = status;
if (message) {
this.setAttribute('status.message', message);
}
}
addEvent(name: string, attributes?: SpanAttributes): void {
this.events.push({
name,
timestamp: new Date(),
attributes,
});
}
recordException(exception: Error): void {
this.setStatus(SpanStatus.ERROR);
this.setAttribute('exception.type', exception.name);
this.setAttribute('exception.message', exception.message);
if (exception.stack) {
this.setAttribute('exception.stacktrace', exception.stack);
}
}
end(): void {
if (!this.endTime) {
this.endTime = new Date();
if (this.status === SpanStatus.UNSET) {
this.status = SpanStatus.OK;
}
}
}
/**
* Get duration in milliseconds
*/
getDuration(): number | null {
if (!this.endTime) return null;
return this.endTime.getTime() - this.startTime.getTime();
}
/**
* Convert span to JSON
*/
toJSON(): Record<string, unknown> {
return {
name: this.name,
traceId: this.context.traceId,
spanId: this.context.spanId,
parentSpanId: this.parentSpanId,
startTime: this.startTime.toISOString(),
endTime: this.endTime?.toISOString(),
duration: this.getDuration(),
status: this.status,
attributes: this.attributes,
events: this.events,
};
}
}
/**
* Span exporter interface
*/
export interface SpanExporter {
export(spans: Span[]): void | Promise<void>;
}
/**
* Console span exporter for debugging
*/
export class ConsoleSpanExporter implements SpanExporter {
export(spans: Span[]): void {
for (const span of spans) {
if (span instanceof InMemorySpan) {
console.log('[TRACE]', JSON.stringify(span.toJSON(), null, 2));
}
}
}
}
/**
* In-memory tracer implementation
*/
export class InMemoryTracer implements Tracer {
private activeSpan: Span | null = null;
private spans: Span[] = [];
private exporter: SpanExporter;
constructor(exporter: SpanExporter = new ConsoleSpanExporter()) {
this.exporter = exporter;
}
startSpan(name: string, attributes?: SpanAttributes): Span {
const parentContext = this.activeSpan?.context;
const span = new InMemorySpan(name, attributes, parentContext);
this.spans.push(span);
return span;
}
getActiveSpan(): Span | null {
return this.activeSpan;
}
async withSpan<T>(name: string, fn: (span: Span) => Promise<T>): Promise<T> {
const span = this.startSpan(name);
const previousActiveSpan = this.activeSpan;
this.activeSpan = span;
try {
const result = await fn(span);
span.setStatus(SpanStatus.OK);
return result;
} catch (error) {
span.recordException(error as Error);
throw error;
} finally {
span.end();
this.activeSpan = previousActiveSpan;
this.exportSpan(span);
}
}
private exportSpan(span: Span): void {
try {
const result = this.exporter.export([span]);
if (result && typeof result.then === 'function') {
result.catch((err) => {
console.error('Span export error:', err);
});
}
} catch (err) {
console.error('Span export error:', err);
}
}
/**
* Get all recorded spans
*/
getSpans(): Span[] {
return [...this.spans];
}
/**
* Clear all spans
*/
clear(): void {
this.spans = [];
}
/**
* Create context carrier for propagation
*/
inject(carrier: Record<string, string>): void {
if (this.activeSpan) {
const { traceId, spanId, traceFlags } = this.activeSpan.context;
carrier['traceparent'] = `00-${traceId}-${spanId}-${traceFlags.toString(16).padStart(2, '0')}`;
}
}
/**
* Extract context from carrier
*/
extract(carrier: Record<string, string>): SpanContext | null {
const traceparent = carrier['traceparent'];
if (!traceparent) return null;
const parts = traceparent.split('-');
if (parts.length !== 4) return null;
return {
traceId: parts[1],
spanId: parts[2],
traceFlags: parseInt(parts[3], 16),
};
}
}
/**
* No-op tracer for when tracing is disabled
*/
class NoOpSpan implements Span {
name = '';
startTime = new Date();
status = SpanStatus.UNSET;
attributes = {};
context: SpanContext = {
traceId: '00000000000000000000000000000000',
spanId: '0000000000000000',
traceFlags: 0,
};
setAttribute(): void {}
setAttributes(): void {}
setStatus(): void {}
addEvent(): void {}
end(): void {}
recordException(): void {}
}
class NoOpTracer implements Tracer {
private noOpSpan = new NoOpSpan();
startSpan(): Span {
return this.noOpSpan;
}
getActiveSpan(): Span | null {
return null;
}
async withSpan<T>(_name: string, fn: (span: Span) => Promise<T>): Promise<T> {
return fn(this.noOpSpan);
}
}
/**
* Default tracer instance
*/
export const defaultTracer: Tracer = new InMemoryTracer();
/**
* No-op tracer instance (for performance-sensitive scenarios)
*/
export const noOpTracer: Tracer = new NoOpTracer();
/**
* Tracing provider configuration
*/
export interface TracingConfig {
enabled: boolean;
exporter?: SpanExporter;
serviceName?: string;
serviceVersion?: string;
}
/**
* Tracing provider
*/
export class TracingProvider {
private tracer: Tracer;
private config: TracingConfig;
constructor(config: Partial<TracingConfig> = {}) {
this.config = {
enabled: config.enabled !== false,
exporter: config.exporter,
serviceName: config.serviceName || 'elasticsearch-client',
serviceVersion: config.serviceVersion,
};
this.tracer = this.config.enabled
? new InMemoryTracer(this.config.exporter)
: noOpTracer;
}
getTracer(): Tracer {
return this.tracer;
}
isEnabled(): boolean {
return this.config.enabled;
}
createSpan(name: string, attributes?: SpanAttributes): Span {
const span = this.tracer.startSpan(name, {
...attributes,
'service.name': this.config.serviceName,
...(this.config.serviceVersion && { 'service.version': this.config.serviceVersion }),
});
return span;
}
async withSpan<T>(name: string, fn: (span: Span) => Promise<T>): Promise<T> {
return this.tracer.withSpan(name, fn);
}
propagateContext(carrier: Record<string, string>): void {
if (this.tracer instanceof InMemoryTracer) {
this.tracer.inject(carrier);
}
}
extractContext(carrier: Record<string, string>): SpanContext | null {
if (this.tracer instanceof InMemoryTracer) {
return this.tracer.extract(carrier);
}
return null;
}
}
/**
* Default tracing provider
*/
export const defaultTracingProvider = new TracingProvider();