From f7db7c12903cb1e9fa710511a1d46c90f39b4cc3 Mon Sep 17 00:00:00 2001 From: Jan Hapke Date: Sat, 11 Jul 2026 02:46:40 +0200 Subject: [PATCH 1/9] feat: added tracing implementation --- package.json | 11 + src/eimer/Eimer.ts | 66 ++++-- src/eimer/EimerDependencies.ts | 4 +- src/eimer/EimerMessage.ts | 7 +- src/eimer/EimerMessageMeta.ts | 18 ++ src/eimer/PayloadHandler.ts | 6 +- src/eimer/RpcHandler.ts | 4 +- src/eimer/RpcManager.ts | 6 +- src/eimer/TraceContext.ts | 12 + src/eimer/TraceSpan.ts | 13 ++ src/eimer/Tracer.ts | 37 +++ src/eimer/TracerBackend.ts | 22 ++ src/eimer/index.ts | 5 + src/message-bus/EimerMessageBus.ts | 38 +++- src/rpc/EimerRpcManager.ts | 75 ++++-- src/rpc/EimerRpcRequestMessage.ts | 6 +- src/rpc/EimerRpcResponseMessage.ts | 6 +- src/tracing/ConsoleLoggingTracerBackend.ts | 145 ++++++++++++ src/tracing/EimerLoggerTracerBackend.ts | 21 ++ src/tracing/EimerNoopTracer.ts | 35 +++ src/tracing/EimerPublishingTracerBackend.ts | 24 ++ src/tracing/EimerTraceSubscriber.ts | 27 +++ src/tracing/EimerTracer.ts | 71 ++++++ src/tracing/MemoryTracerBackend.ts | 23 ++ src/tracing/index.ts | 8 + src/tracing/now.ts | 10 + tests/eimer/Eimer.test.ts | 175 +++++++++++++- tests/eimer/EimerMessage.test.ts | 13 ++ tests/message-bus/EimerMessageBus.test.ts | 97 +++++++- tests/rpc/EimerRpcManager.test.ts | 177 +++++++++++++- tests/rpc/EimerRpcRequestMessage.test.ts | 8 + tests/rpc/EimerRpcResponseMessage.test.ts | 8 + .../ConsoleLoggingTracerBackend.test.ts | 213 +++++++++++++++++ .../tracing/EimerLoggerTracerBackend.test.ts | 40 ++++ tests/tracing/EimerNoopTracer.test.ts | 65 ++++++ .../EimerPublishingTracerBackend.test.ts | 92 ++++++++ tests/tracing/EimerTraceSubscriber.test.ts | 111 +++++++++ tests/tracing/EimerTracer.test.ts | 215 ++++++++++++++++++ tests/tracing/MemoryTracerBackend.test.ts | 82 +++++++ tests/tracing/now.test.ts | 22 ++ 40 files changed, 1956 insertions(+), 62 deletions(-) create mode 100644 src/eimer/EimerMessageMeta.ts create mode 100644 src/eimer/TraceContext.ts create mode 100644 src/eimer/TraceSpan.ts create mode 100644 src/eimer/Tracer.ts create mode 100644 src/eimer/TracerBackend.ts create mode 100644 src/tracing/ConsoleLoggingTracerBackend.ts create mode 100644 src/tracing/EimerLoggerTracerBackend.ts create mode 100644 src/tracing/EimerNoopTracer.ts create mode 100644 src/tracing/EimerPublishingTracerBackend.ts create mode 100644 src/tracing/EimerTraceSubscriber.ts create mode 100644 src/tracing/EimerTracer.ts create mode 100644 src/tracing/MemoryTracerBackend.ts create mode 100644 src/tracing/index.ts create mode 100644 src/tracing/now.ts create mode 100644 tests/tracing/ConsoleLoggingTracerBackend.test.ts create mode 100644 tests/tracing/EimerLoggerTracerBackend.test.ts create mode 100644 tests/tracing/EimerNoopTracer.test.ts create mode 100644 tests/tracing/EimerPublishingTracerBackend.test.ts create mode 100644 tests/tracing/EimerTraceSubscriber.test.ts create mode 100644 tests/tracing/EimerTracer.test.ts create mode 100644 tests/tracing/MemoryTracerBackend.test.ts create mode 100644 tests/tracing/now.test.ts diff --git a/package.json b/package.json index 4abc2a7..0cf178b 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "nodejs", "eimerjs" ], + "sideEffects": false, "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", @@ -32,6 +33,16 @@ "types": "./dist/cjs/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" + }, + "./tracing": { + "types": "./dist/cjs/tracing/index.d.ts", + "import": "./dist/esm/tracing/index.js", + "require": "./dist/cjs/tracing/index.js" + } + }, + "typesVersions": { + "*": { + "tracing": ["./dist/cjs/tracing/index.d.ts"] } }, "files": [ diff --git a/src/eimer/Eimer.ts b/src/eimer/Eimer.ts index 8f691b8..ddece34 100644 --- a/src/eimer/Eimer.ts +++ b/src/eimer/Eimer.ts @@ -5,8 +5,10 @@ import { EimerIdGenerator } from '../id-generator/EimerIdGenerator'; import { EimerLogger } from '../logger/EimerLogger'; import { EimerMessageBus } from '../message-bus/EimerMessageBus'; import { EimerRpcManager } from '../rpc/EimerRpcManager'; +import { EimerNoopTracer } from '../tracing/EimerNoopTracer'; import type { EimerDependencies } from './EimerDependencies'; +import type { EimerMessageMeta } from './EimerMessageMeta'; import type { EimerOptions } from './EimerOptions'; import type { IdGenerator } from './IdGenerator'; import type { Logger } from './Logger'; @@ -18,6 +20,7 @@ import type { RpcHandler } from './RpcHandler'; import type { RpcManager } from './RpcManager'; import type { RpcMethod } from './RpcMethod'; import type { Topic } from './Topic'; +import type { Tracer } from './Tracer'; import type { Transport } from './Transport'; /** @@ -26,7 +29,7 @@ import type { Transport } from './Transport'; * It encapsulates all user-facing functionality in a single class. * Allows connecting one or more transports. * Provides Publish/Subscribe and remote procedure call patterns. - * + * * To use it, create an instance with a unique node id and connect a transport * After that, publish/subscribe and RPC can be used. */ @@ -36,12 +39,13 @@ export class Eimer { private readonly messageBus: MessageBus; private readonly rpcManager: RpcManager; private readonly logger: Logger; + private readonly tracer: Tracer; // Map to track user handler to wrapped handler for correct unsubscribe // Nested map: topic -> payloadHandler -> messageHandler private handlerMap: Map> = new Map(); /** - * + * * @param nodeId id of the node this instance belongs to. should be unique across all instances * @param options * @param dependencies optionally inject dependencies @@ -50,8 +54,9 @@ export class Eimer { this.nodeId = nodeId; this.idGenerator = dependencies?.idGenerator ?? new EimerIdGenerator(); this.logger = dependencies?.logger ?? new EimerLogger(options?.logLevel ?? LogLevel.DEBUG, options?.logModuleName ?? 'Eimer', this.nodeId); - this.messageBus = dependencies?.messageBus ?? new EimerMessageBus(this.nodeId, options); - this.rpcManager = dependencies?.rpcManager ?? new EimerRpcManager(this.nodeId, this.messageBus, options); + this.tracer = dependencies?.tracer ?? new EimerNoopTracer(); + this.messageBus = dependencies?.messageBus ?? new EimerMessageBus(this.nodeId, options, { tracer: this.tracer }); + this.rpcManager = dependencies?.rpcManager ?? new EimerRpcManager(this.nodeId, this.messageBus, options, { tracer: this.tracer }); } /** @@ -71,19 +76,20 @@ export class Eimer { this.logger.debug('Disconnecting Transport', { transport }); this.messageBus.disconnectTransport(transport); } - + /** * Call an RPC method * Will time out if no handler exists or handler does not respond * Will throw on errors from handler * @param method name of method / rpc handler * @param params optional params object - * @param timeout override timeout from constructor options for this single call + * @param timeoutMs override timeout, in milliseconds, from constructor options for this single call + * @param meta optional metadata to attach to the request * @returns result from invoked handler */ - async callRpc(method: RpcMethod, params?: unknown, timeout?: number): Promise { + async callRpc(method: RpcMethod, params?: unknown, timeoutMs?: number, meta?: EimerMessageMeta): Promise { this.logger.debug('Making RPC call', { method, params }); - return this.rpcManager.call(method, params, timeout); + return this.rpcManager.call(method, params, timeoutMs, meta); } /** @@ -112,17 +118,32 @@ export class Eimer { * Will send payload to all subscribers on the topic * @param topic name of topic - subscribers can subscribe to it * @param payload payload will be sent to subscribers + * @param meta optional metadata to attach to the message */ - publish(topic: Topic, payload?: unknown): void { + publish(topic: Topic, payload?: unknown, meta?: EimerMessageMeta): void { this.logger.debug('Publishing message', { topic }); + + if (meta?.collectEimerTraces && meta.trace) { + meta.trace = this.tracer.startSpan( + topic, + { isEimerTrace: true, node: this.nodeId, component: 'Eimer', op: 'publish' }, + meta.trace + ); + } + this.messageBus.publish( new EimerMessage( this.idGenerator.generateId(), this.nodeId, topic, - payload + payload, + meta ) ); + + if (meta?.collectEimerTraces && meta.trace) { + this.tracer.endSpan(meta?.trace?.spanId); + } } /** @@ -135,9 +156,23 @@ export class Eimer { subscribe(topic: Topic, payloadHandler: PayloadHandler): () => void { this.logger.debug('Subscribing to topic', { topic }); const messageHandler = (message: EimerMessage) => { - payloadHandler(message.payload); + + let trace = message.meta?.trace; + if (message.meta?.collectEimerTraces && trace) { + trace = this.tracer.startSpan( + topic, + { isEimerTrace: true, node: this.nodeId, component: 'Eimer', op: 'handlePayload' }, + trace + ); + } + + payloadHandler(message.payload, trace ? { ...message.meta, trace } : message.meta); + + if (message.meta?.collectEimerTraces && trace) { + this.tracer.endSpan(trace.spanId); + } }; - + // Get or create the topic's handler map let topicHandlerMap = this.handlerMap.get(topic); if (!topicHandlerMap) { @@ -145,7 +180,7 @@ export class Eimer { this.handlerMap.set(topic, topicHandlerMap); } topicHandlerMap.set(payloadHandler, messageHandler); - + const unsubscribe = this.messageBus.subscribe(topic, messageHandler); return () => { unsubscribe(); @@ -201,12 +236,13 @@ export class Eimer { /** * Shutdown this Eimer instance - * Will reject all RPC requests that are still waiting for a response - * and shutdown the MessageBus + * Will reject all RPC requests that are still waiting for a response, + * shutdown the MessageBus, and shutdown the Tracer */ shutdown(): void { this.rpcManager.shutdown(); this.messageBus.shutdown(); + this.tracer.shutdown(); } } diff --git a/src/eimer/EimerDependencies.ts b/src/eimer/EimerDependencies.ts index c205818..e28e14b 100644 --- a/src/eimer/EimerDependencies.ts +++ b/src/eimer/EimerDependencies.ts @@ -2,10 +2,11 @@ import type { IdGenerator } from "./IdGenerator"; import type { Logger } from "./Logger"; import type { MessageBus } from "./MessageBus"; import type { RpcManager } from "./RpcManager"; +import type { Tracer } from "./Tracer"; /** * Base Interface for dependencies injected into constructors - * + * * Extensions of this interface are used by Eimer classes * to allow constructor-based dependency injection. */ @@ -14,4 +15,5 @@ export interface EimerDependencies { messageBus?: MessageBus, rpcManager?: RpcManager, logger?: Logger, + tracer?: Tracer, }; diff --git a/src/eimer/EimerMessage.ts b/src/eimer/EimerMessage.ts index 98f4aa6..e1f1972 100644 --- a/src/eimer/EimerMessage.ts +++ b/src/eimer/EimerMessage.ts @@ -1,3 +1,4 @@ +import type { EimerMessageMeta } from './EimerMessageMeta'; import type { MagicMessageMarker } from './MagicMessageMarker'; import type { MessageId } from './MessageId'; import type { NodeId } from './NodeId'; @@ -14,24 +15,28 @@ export class EimerMessage implements StructuredCloneable { public readonly from: NodeId; public readonly topic: Topic; public readonly payload?: unknown; + public readonly meta?: EimerMessageMeta | undefined; /** - * + * * @param id globally unique id of this message - for example generated by an `IdGenerator` * @param from nodeId that sent this message * @param topic topic this message was sent on * @param payload optional payload + * @param meta optional metadata */ constructor( id: MessageId, from: NodeId, topic: Topic, payload?: unknown, + meta?: EimerMessageMeta ) { this.id = id; this.from = from; this.topic = topic; this.payload = payload; + this.meta = meta; } /** diff --git a/src/eimer/EimerMessageMeta.ts b/src/eimer/EimerMessageMeta.ts new file mode 100644 index 0000000..f91105a --- /dev/null +++ b/src/eimer/EimerMessageMeta.ts @@ -0,0 +1,18 @@ +import type { TraceContext } from './TraceContext'; + +/** + * Eimer Message Metadata + * + * Metadata that can optionally be passed along with a message. + */ +export interface EimerMessageMeta { + /** optional trace context */ + trace?: TraceContext; + + /** + * set to true to collect traces from eimer-internals while routing this message + * `trace` must be set when `collectEimerTraces` is `true` because traces will + * be injected into the TraceContext + */ + collectEimerTraces?: boolean; +} diff --git a/src/eimer/PayloadHandler.ts b/src/eimer/PayloadHandler.ts index ba83b9c..4b3aec9 100644 --- a/src/eimer/PayloadHandler.ts +++ b/src/eimer/PayloadHandler.ts @@ -1,6 +1,8 @@ +import type { EimerMessageMeta } from './EimerMessageMeta'; + /** * A Payload can be an arbitrary object sent through an Eimer "network" - * + * * A Payload Handler is a function that is involved when a payload is received. */ -export type PayloadHandler = (payload: unknown) => void; +export type PayloadHandler = (payload: unknown, meta?: EimerMessageMeta) => void; diff --git a/src/eimer/RpcHandler.ts b/src/eimer/RpcHandler.ts index 082a4d6..c6a3b51 100644 --- a/src/eimer/RpcHandler.ts +++ b/src/eimer/RpcHandler.ts @@ -1,5 +1,7 @@ +import type { EimerMessageMeta } from './EimerMessageMeta'; + /** * An RPC Handler is the implementation of an RPC method that is invoked * whenever the RPC method is called. */ -export type RpcHandler = (params?: unknown) => Promise; +export type RpcHandler = (params?: unknown, meta?: EimerMessageMeta) => Promise; diff --git a/src/eimer/RpcManager.ts b/src/eimer/RpcManager.ts index 513d94c..e38598d 100644 --- a/src/eimer/RpcManager.ts +++ b/src/eimer/RpcManager.ts @@ -1,3 +1,4 @@ +import type { EimerMessageMeta } from './EimerMessageMeta'; import type { RpcHandler } from './RpcHandler'; import type { RpcMethod } from './RpcMethod'; @@ -34,9 +35,10 @@ export interface RpcManager { * Will asynchronously return the response or throw an error * @param method the method name * @param params optional, arbitrary parameters/arguments passed to the handler - * @param timeout optional timeout in milliseconds after which the call will reject + * @param timeoutMs optional timeout, in milliseconds, after which the call will reject + * @param meta optional metadata */ - call(method: RpcMethod, params?: unknown, timeout?: number): Promise; + call(method: RpcMethod, params?: unknown, timeoutMs?: number, meta?: EimerMessageMeta): Promise; /** * Unregister an RPC handler diff --git a/src/eimer/TraceContext.ts b/src/eimer/TraceContext.ts new file mode 100644 index 0000000..15ae2fd --- /dev/null +++ b/src/eimer/TraceContext.ts @@ -0,0 +1,12 @@ +/** + * Trace Context + * + * Identifies a span within a distributed trace. + * Property names mirror W3C Trace Context / OpenTelemetry: + * A "trace" instruments a high-level operation and consists of + * multiple "spans", which can form a tree under the trace. + */ +export interface TraceContext { + traceId: string; + spanId: string; +} diff --git a/src/eimer/TraceSpan.ts b/src/eimer/TraceSpan.ts new file mode 100644 index 0000000..b583fd6 --- /dev/null +++ b/src/eimer/TraceSpan.ts @@ -0,0 +1,13 @@ +/** + * A single completed span, local to the node that recorded it. + * Kept as generic as possible and shaped like the spans of Perfetto / OpenTelemetry + */ +export interface TraceSpan { + traceId: string; + spanId: string; + parentSpanId?: string; + name: string; + metadata?: Record; + startedAt: number; + durationMs: number; +} diff --git a/src/eimer/Tracer.ts b/src/eimer/Tracer.ts new file mode 100644 index 0000000..d91936d --- /dev/null +++ b/src/eimer/Tracer.ts @@ -0,0 +1,37 @@ +import type { TraceContext } from './TraceContext'; +import type { TracerBackend } from './TracerBackend'; + +/** + * Tracer Interface + * + * Defines an object Eimer interacts with to measure execution time of internal operations + * + * The collected tracing information is handed to a "TracerBackend" for storage and/or processing + */ +export interface Tracer { + /** Attach (or replace) the `TracerBackend` this `Tracer` forwards spans to. */ + setBackend(backend: TracerBackend): void; + + /** + * Start a fresh trace from scratch or append a new span to an existing parent trace + * if parent is undefined - creates a new traceId + spanId + * if parent is defined - reuses traceId and creates a new span with the correct parent + * @param name free-form span name (e.g. an RPC method or topic) + * @param metadata free-form, e.g. `{ node, component, op }` + * @param parent parent trace context - if set, the new trace becomes a sub-trace of the parent + */ + startSpan(name: string, metadata?: Record, parent?: TraceContext): TraceContext; + + /** + * Ends the span identified by `spanId` and forwards it to the configured `TracerBackend` + * @param spanId traceId - if undefined or unknown id, "endSpan" does nothing + * @param metadata merged into the span's metadata at close + */ + endSpan(spanId?: string, additionalMetadata?: Record): void; + + /** + * Releases any resources held by this `Tracer`. + * Also calls shutdown on the TracerBackend + */ + shutdown(): void; +} diff --git a/src/eimer/TracerBackend.ts b/src/eimer/TracerBackend.ts new file mode 100644 index 0000000..d201302 --- /dev/null +++ b/src/eimer/TracerBackend.ts @@ -0,0 +1,22 @@ +import type { TraceSpan } from './TraceSpan'; + +/** + * TracerBackend Interface + * + * Defines an object used by a `Tracer` to store or process `TraceSpan`s + */ +export interface TracerBackend { + /** + * handle a new span + * called by `Tracer` whenever a span has "ended". + * Should process or store or forward the span. + * @param span + */ + handleSpan(span: TraceSpan): void; + + /** + * Shutdown this backend. + * Should release resources or persist traces to storage etc. + */ + shutdown?(): void; +} diff --git a/src/eimer/index.ts b/src/eimer/index.ts index c3be908..4e363d0 100644 --- a/src/eimer/index.ts +++ b/src/eimer/index.ts @@ -1,6 +1,7 @@ export * from './Eimer'; export * from './EimerDependencies'; export * from './EimerMessage'; +export * from './EimerMessageMeta'; export * from './EimerOptions'; export * from './IdGenerator'; export * from './Logger'; @@ -16,4 +17,8 @@ export * from './RpcManager'; export * from './RpcMethod'; export * from './StructuredCloneable'; export * from './Topic'; +export * from './TraceContext'; +export * from './Tracer'; +export * from './TracerBackend'; +export * from './TraceSpan'; export * from './Transport'; diff --git a/src/message-bus/EimerMessageBus.ts b/src/message-bus/EimerMessageBus.ts index ed08ff2..1ff2d39 100644 --- a/src/message-bus/EimerMessageBus.ts +++ b/src/message-bus/EimerMessageBus.ts @@ -1,8 +1,9 @@ -import { EimerMessage } from '../eimer/EimerMessage'; import { LogLevel } from '../eimer/LogLevel'; import { EimerLogger } from '../logger/EimerLogger'; +import { EimerNoopTracer } from '../tracing/EimerNoopTracer'; import type { EimerDependencies } from '../eimer/EimerDependencies'; +import type { EimerMessage } from '../eimer/EimerMessage'; import type { EimerOptions } from '../eimer/EimerOptions'; import type { Logger } from '../eimer/Logger'; import type { MessageBus } from '../eimer/MessageBus'; @@ -10,17 +11,19 @@ import type { MessageHandler } from '../eimer/MessageHandler'; import type { MessageId } from '../eimer/MessageId'; import type { NodeId } from '../eimer/NodeId'; import type { Topic } from '../eimer/Topic'; +import type { Tracer } from '../eimer/Tracer'; import type { Transport } from '../eimer/Transport'; export interface EimerMessageBusOptions extends Pick { } -export interface EimerMessageBusDependencies extends Pick { +export interface EimerMessageBusDependencies extends Pick { } export class EimerMessageBus implements MessageBus { private readonly nodeId: NodeId; private logger: Logger; + private readonly tracer: Tracer; private readonly handlers: Map> = new Map(); private readonly transports: Set = new Set(); private readonly seenMessages: Set = new Set(); // @todo: only store the last X message ids and purge the rest @@ -28,6 +31,7 @@ export class EimerMessageBus implements MessageBus { constructor(nodeId: NodeId, options?:EimerMessageBusOptions, dependencies?: EimerMessageBusDependencies) { this.nodeId = nodeId; this.logger = dependencies?.logger ?? new EimerLogger(options?.logLevel ?? LogLevel.DEBUG, options?.logModuleName ?? 'MessageBus', this.nodeId); + this.tracer = dependencies?.tracer ?? new EimerNoopTracer(); } connectTransport(transport: Transport) { @@ -55,7 +59,7 @@ export class EimerMessageBus implements MessageBus { // Handle locally first this.handleMessage(message); - // Forward to all transports + // Forward to all transports this.transports.forEach(transport => { try { transport.send(message); @@ -139,12 +143,36 @@ export class EimerMessageBus implements MessageBus { this.handleMessage(message); // Forward to all other transports (excluding the source transport to prevent loops) - Array.from(this.transports).filter(otherTransport => otherTransport !== sourceTransport).forEach(otherTransport => { + + const otherTransports = Array.from(this.transports).filter(otherTransport => otherTransport !== sourceTransport); + if (otherTransports.length === 0) { + return; + } + + let forwardMessage = message; + if (message.meta?.collectEimerTraces && message.meta?.trace) { + const relayTrace = this.tracer.startSpan( + message.topic, + { isEimerTrace: true, node: this.nodeId, component: 'Eimer', op: 'forward' }, + message.meta.trace + ); + // clone message before forwarding so trace data is kept independent + forwardMessage = { + ...message, + meta: { ...message.meta, trace: relayTrace } + }; + } + + otherTransports.forEach(otherTransport => { try { - otherTransport.send(message); + otherTransport.send(forwardMessage); } catch (error) { this.logger.error('Error forwarding message through transport', { error }); } }); + + if (message.meta?.collectEimerTraces && message.meta?.trace) { + this.tracer.endSpan(forwardMessage.meta?.trace?.spanId); + } } } diff --git a/src/rpc/EimerRpcManager.ts b/src/rpc/EimerRpcManager.ts index ab960f2..027ce81 100644 --- a/src/rpc/EimerRpcManager.ts +++ b/src/rpc/EimerRpcManager.ts @@ -3,14 +3,16 @@ import { EimerRpcRequestPayload } from './EimerRpcRequestPayload'; import { EimerRpcResponseMessage } from './EimerRpcResponseMessage'; import { EimerRpcResponsePayload } from './EimerRpcResponsePayload'; -import { EimerMessage } from '../eimer/EimerMessage'; import { LogLevel } from '../eimer/LogLevel'; import { EimerIdGenerator } from '../id-generator/EimerIdGenerator'; import { EimerLogger } from '../logger/EimerLogger'; +import { EimerNoopTracer } from '../tracing/EimerNoopTracer'; import type { RpcRequestId } from './RpcRequestId'; import type { EimerDependencies } from '../eimer/EimerDependencies'; +import type { EimerMessage } from '../eimer/EimerMessage'; +import type { EimerMessageMeta } from '../eimer/EimerMessageMeta'; import type { EimerOptions } from '../eimer/EimerOptions'; import type { IdGenerator } from '../eimer/IdGenerator'; import type { Logger } from '../eimer/Logger'; @@ -19,16 +21,17 @@ import type { NodeId } from '../eimer/NodeId'; import type { RpcHandler } from '../eimer/RpcHandler'; import type { RpcManager } from '../eimer/RpcManager'; import type { RpcMethod } from '../eimer/RpcMethod'; +import type { Tracer } from '../eimer/Tracer'; export interface EimerRpcManagerOptions extends Pick { /** - * timeout (in milliseconds) after which RPC calls are rejected + * timeout, in milliseconds, after which RPC calls are rejected * can be overriden for individual RPC calls */ - timeout?: number + timeoutMs?: number } -export interface EimerRpcManagerDependencies extends Pick { +export interface EimerRpcManagerDependencies extends Pick { } /** @@ -39,12 +42,15 @@ export class EimerRpcManager implements RpcManager { private readonly idGenerator: IdGenerator; private readonly messageBus: MessageBus; private readonly logger: Logger; + private readonly tracer: Tracer; // Stores RPC calls that are in progress, indexed by requestId private readonly pendingRequests: Map void; reject: (reason: unknown) => void; timeout: ReturnType; // timeout id returned from "setTimeout" + method: RpcMethod; + meta?: EimerMessageMeta | undefined; }> = new Map(); // Stores registered RPC Handlers indexed by RPC method name @@ -54,21 +60,22 @@ export class EimerRpcManager implements RpcManager { private readonly unsubscriberFunctions: Map void> = new Map(); // Timeout for RPC calls, in milliseconds - private timeout: number = 1_000; + private timeoutMs: number = 1_000; private isSubscribedToResponses: boolean = false; /** - * + * * @param nodeId ID of node that hosts this RPC Manager * @param messageBus MessageBus to use for communication */ constructor(nodeId: NodeId, messageBus: MessageBus, options?: EimerRpcManagerOptions, dependencies?: EimerRpcManagerDependencies) { this.nodeId = nodeId; this.messageBus = messageBus; - this.timeout = (options?.timeout !== null && options?.timeout !== undefined) ? options?.timeout : 1_000; + this.timeoutMs = (options?.timeoutMs !== null && options?.timeoutMs !== undefined) ? options?.timeoutMs : 1_000; this.idGenerator = dependencies?.idGenerator ?? new EimerIdGenerator(); this.logger = dependencies?.logger ?? new EimerLogger(options?.logLevel ?? LogLevel.DEBUG, options?.logModuleName ?? 'RpcManager', this.nodeId); + this.tracer = dependencies?.tracer ?? new EimerNoopTracer(); } /** @@ -87,10 +94,11 @@ export class EimerRpcManager implements RpcManager { * Call a remote method and await the response (or error) * @param method method name * @param params parameters for call - * @param timeout timeout for call (in ms) - * @returns + * @param timeoutMs timeout for call, in milliseconds + * @param meta optional metadata + * @returns */ - async call(method: RpcMethod, params?: unknown, timeout?: number): Promise { + async call(method: RpcMethod, params?: unknown, timeoutMs?: number, meta?: EimerMessageMeta): Promise { if (!this.isSubscribedToResponses) { this.messageBus.subscribe(EimerRpcResponseMessage.TOPIC_PREFIX, this.handleResponseMessage.bind(this)); this.isSubscribedToResponses = true; @@ -98,15 +106,26 @@ export class EimerRpcManager implements RpcManager { return new Promise((resolve, reject) => { const requestId = method + ':' + this.idGenerator.generateId(); + if (meta?.collectEimerTraces && meta?.trace) { + meta.trace = this.tracer.startSpan( + method, + { isEimerTrace: true, node: this.nodeId, component: 'EimerRpcManager', op: 'call' }, + meta.trace + ); + } + const timeoutId = setTimeout(() => { this.pendingRequests.delete(requestId); + this.tracer.endSpan(meta?.trace?.spanId, { eimerRpcTimeout: true }); reject(new Error(`RPC call of ${method} timed out`)); - }, timeout || this.timeout); + }, timeoutMs || this.timeoutMs); this.pendingRequests.set(requestId, { resolve: resolve as (value: unknown) => void, reject, - timeout: timeoutId + timeout: timeoutId, + method, + meta }); const requestMessage = new EimerRpcRequestMessage( @@ -117,7 +136,8 @@ export class EimerRpcManager implements RpcManager { requestId, method, params - ) + ), + meta ); this.logger.debug('Publishing RPC request', { requestId, method, params, requestMessage }); @@ -141,7 +161,7 @@ export class EimerRpcManager implements RpcManager { /** * shutdown the RPC manager - * + * * unregisters all handlers, rejects all pending calls and shuts down the message bus */ shutdown(): void { @@ -150,13 +170,16 @@ export class EimerRpcManager implements RpcManager { } for (const request of this.pendingRequests.values()) { request.reject('RPC Manager shutdown'); + if (request?.meta?.collectEimerTraces && request?.meta?.trace) { + this.tracer.endSpan(request.meta?.trace?.spanId, { eimerRpcShutdown: true }); + } } this.messageBus.shutdown(); } /** * handle a response message received from the Message Bus - * + * * If the message is a response to an RPC call made by this instance, extract the error/result * and resolve the promise that corresponds to the call. */ @@ -176,6 +199,8 @@ export class EimerRpcManager implements RpcManager { clearTimeout(pendingRequest.timeout); this.pendingRequests.delete(payload.requestId); + this.tracer.endSpan(pendingRequest.meta?.trace?.spanId); + if (payload.error) { this.logger.debug('Rejecting RPC response', { payload }); pendingRequest.reject(payload.error); @@ -187,7 +212,7 @@ export class EimerRpcManager implements RpcManager { /** * handle a request message received from the Message Bus - * + * * If the message is an RPC call for which this instance has a registered handler, * call the handler and send the result or error response back to the Message Bus */ @@ -203,11 +228,20 @@ export class EimerRpcManager implements RpcManager { return; } + let trace = requestMessage.meta?.trace; + if (requestMessage.meta?.collectEimerTraces && trace) { + trace = this.tracer.startSpan( + requestPayload.method, + { isEimerTrace: true, node: this.nodeId, component: 'EimerRpcManager', op: 'handleRequestMessage' }, + trace + ); + } + let responsePayload: EimerRpcResponsePayload; try { this.logger.debug('Calling RPC Handler', { requestId: requestPayload.requestId, method: requestPayload.method, params: requestPayload.params }); - const result = await handler(requestPayload.params); + const result = await handler(requestPayload.params, trace ? { ...requestMessage.meta, trace } : requestMessage.meta); responsePayload = new EimerRpcResponsePayload( requestPayload.requestId, undefined, @@ -220,13 +254,18 @@ export class EimerRpcManager implements RpcManager { error, undefined ); + } finally { + if (requestMessage.meta?.collectEimerTraces && trace) { + this.tracer.endSpan(trace?.spanId); + } } const responseMessage = new EimerRpcResponseMessage( this.idGenerator.generateId(), this.nodeId, EimerRpcResponseMessage.TOPIC_PREFIX, - responsePayload + responsePayload, + trace ? { ...requestMessage.meta, trace } : requestMessage.meta ) this.logger.debug('Publishing RPC Response', { requestId: requestPayload.requestId, method: requestPayload.method, responseMessage }); diff --git a/src/rpc/EimerRpcRequestMessage.ts b/src/rpc/EimerRpcRequestMessage.ts index f09ecce..28bfc7d 100644 --- a/src/rpc/EimerRpcRequestMessage.ts +++ b/src/rpc/EimerRpcRequestMessage.ts @@ -5,6 +5,7 @@ import { EimerMessage } from '../eimer/EimerMessage'; import type { MessageId } from '../eimer/MessageId'; import type { NodeId } from '../eimer/NodeId'; import type { Topic } from '../eimer/Topic'; +import type { EimerMessageMeta } from '../eimer/EimerMessageMeta'; /** * RPC Request Message @@ -25,9 +26,10 @@ export class EimerRpcRequestMessage extends EimerMessage { id: MessageId, from: NodeId, topic: Topic, - payload: EimerRpcRequestPayload + payload: EimerRpcRequestPayload, + meta?: EimerMessageMeta ) { - super(id, from, topic, payload); + super(id, from, topic, payload, meta); } /** diff --git a/src/rpc/EimerRpcResponseMessage.ts b/src/rpc/EimerRpcResponseMessage.ts index 6617129..51c4790 100644 --- a/src/rpc/EimerRpcResponseMessage.ts +++ b/src/rpc/EimerRpcResponseMessage.ts @@ -5,6 +5,7 @@ import { EimerMessage } from "../eimer/EimerMessage"; import type { MessageId } from "../eimer/MessageId"; import type { NodeId } from "../eimer/NodeId"; import type { Topic } from "../eimer/Topic"; +import type { EimerMessageMeta } from "../eimer/EimerMessageMeta"; /** * RPC Response Message @@ -27,9 +28,10 @@ export class EimerRpcResponseMessage extends EimerMessage { id: MessageId, from: NodeId, topic: Topic, - payload: EimerRpcResponsePayload + payload: EimerRpcResponsePayload, + meta?: EimerMessageMeta | undefined ) { - super(id, from, topic, payload); + super(id, from, topic, payload, meta); } /** diff --git a/src/tracing/ConsoleLoggingTracerBackend.ts b/src/tracing/ConsoleLoggingTracerBackend.ts new file mode 100644 index 0000000..b077d90 --- /dev/null +++ b/src/tracing/ConsoleLoggingTracerBackend.ts @@ -0,0 +1,145 @@ +import type { TracerBackend } from '../eimer/TracerBackend'; +import type { TraceSpan } from '../eimer/TraceSpan'; + +/** + * Subset of the `console` API this backend needs, so a fake can be injected + * in tests instead of spying on the real global `console`. + */ +export interface GroupingConsole { + group(...args: unknown[]): void; + groupEnd(): void; + log(...args: unknown[]): void; +} + +export interface ConsoleLoggingTracerBackendOptions { + /** milliseconds of inactivity for a `traceId`, measured from the `startedAt` of the last span received for it, before that trace is printed. Default 5000. */ + ttlMs?: number; + /** how often, in milliseconds, to check for traces that have exceeded `ttlMs`. Default 1000. */ + sweepIntervalMs?: number; +} + +/** + * `TracerBackend` that accumulates spans per `traceId` and prints each trace + * as a nested `console.group()` tree once it goes quiet, rather than logging + * every span as it arrives. "Quiet" means no new span has been recorded for + * that `traceId` for `ttlMs`, measured against the `startedAt` of the most + * recently received span (not wall-clock arrival time), checked on a + * `sweepIntervalMs` interval. + * + * This is a heuristic: a trace that may not be complete but has not received + * new spans for longer than `ttlMs` will be printed. + * Any span for it that arrives afterwards is treated as the start of a new trace + * and printed as its own, separate group once it too goes quiet. + */ +export class ConsoleLoggingTracerBackend implements TracerBackend { + private readonly ttlMs: number; + private readonly sweepIntervalMs: number; + private readonly console: GroupingConsole; + + private readonly pendingSpans: Map = new Map(); + private readonly lastStartedAt: Map = new Map(); + private sweepTimer: ReturnType | undefined; + + constructor(options?: ConsoleLoggingTracerBackendOptions, consoleLike?: GroupingConsole) { + this.ttlMs = options?.ttlMs ?? 5_000; + this.sweepIntervalMs = options?.sweepIntervalMs ?? 1_000; + this.console = consoleLike ?? console; + } + + handleSpan(span: TraceSpan): void { + const spans = this.pendingSpans.get(span.traceId) ?? []; + spans.push(span); + this.pendingSpans.set(span.traceId, spans); + this.lastStartedAt.set(span.traceId, span.startedAt); + this.ensureSweeping(); + } + + /** + * Immediately prints any accumulated traces and stops the sweep timer. + * Called via `Eimer.shutdown()` -> `Tracer.shutdown()` if this backend is + * plugged into that node's `Tracer`, so no accumulated spans are + * silently lost and no timer lingers past shutdown. + */ + shutdown(): void { + this.stopSweeping(); + for (const traceId of [...this.pendingSpans.keys()]) { + this.flush(traceId); + } + } + + private ensureSweeping(): void { + if (this.sweepTimer) { + return; + } + this.sweepTimer = setInterval(() => this.sweep(), this.sweepIntervalMs); + // Don't let an idle sweep timer keep a Node process alive. + (this.sweepTimer as { unref?: () => void }).unref?.(); + } + + private stopSweeping(): void { + if (this.sweepTimer) { + clearInterval(this.sweepTimer); + this.sweepTimer = undefined; + } + } + + private sweep(): void { + const now = Date.now(); + for (const [traceId, startedAt] of this.lastStartedAt) { + if (now - startedAt >= this.ttlMs) { + this.flush(traceId); + } + } + if (this.pendingSpans.size === 0) { + this.stopSweeping(); + } + } + + private flush(traceId: string): void { + const spans = this.pendingSpans.get(traceId); + if (!spans) { + return; + } + this.pendingSpans.delete(traceId); + this.lastStartedAt.delete(traceId); + this.printTrace(traceId, spans); + } + + private printTrace(traceId: string, spans: TraceSpan[]): void { + const byParentSpanId = new Map(); + const spanIds = new Set(spans.map(span => span.spanId)); + + for (const span of spans) { + // Parent spans recorded on other nodes may never reach this + // backend, so fall back to treating such spans as roots rather + // than dropping them. + const parentKey = span.parentSpanId && spanIds.has(span.parentSpanId) ? span.parentSpanId : undefined; + const siblings = byParentSpanId.get(parentKey) ?? []; + siblings.push(span); + byParentSpanId.set(parentKey, siblings); + } + + const byStartedAt = (a: TraceSpan, b: TraceSpan): number => a.startedAt - b.startedAt; + + const printSpan = (span: TraceSpan): void => { + const children = (byParentSpanId.get(span.spanId) ?? []).sort(byStartedAt); + const label = `${span.name} (${span.durationMs.toFixed(2)}ms)`; + if (children.length === 0) { + this.console.log(label, span); + return; + } + this.console.group(label, span); + for (const child of children) { + printSpan(child); + } + this.console.groupEnd(); + }; + + const roots = (byParentSpanId.get(undefined) ?? []).sort(byStartedAt); + this.console.group(`trace ${traceId} (${spans.length} span${spans.length === 1 ? '' : 's'})`); + for (const root of roots) { + printSpan(root); + } + this.console.groupEnd(); + } +} diff --git a/src/tracing/EimerLoggerTracerBackend.ts b/src/tracing/EimerLoggerTracerBackend.ts new file mode 100644 index 0000000..5711038 --- /dev/null +++ b/src/tracing/EimerLoggerTracerBackend.ts @@ -0,0 +1,21 @@ +import { LogLevel } from '../eimer/LogLevel'; +import { EimerLogger } from '../logger/EimerLogger'; + +import type { Logger } from '../eimer/Logger'; +import type { TracerBackend } from '../eimer/TracerBackend'; +import type { TraceSpan } from '../eimer/TraceSpan'; + +/** + * A `TracerBackend` that logs every recorded span via `Logger` + */ +export class EimerLoggerTracerBackend implements TracerBackend { + private readonly logger: Logger; + + constructor(logger?: Logger) { + this.logger = logger ?? new EimerLogger(LogLevel.INFO, 'EimerLoggerTracerBackend'); + } + + handleSpan(span: TraceSpan): void { + this.logger.info(`${span.name} (${span.durationMs.toFixed(2)}ms)`, span); + } +} diff --git a/src/tracing/EimerNoopTracer.ts b/src/tracing/EimerNoopTracer.ts new file mode 100644 index 0000000..e66b207 --- /dev/null +++ b/src/tracing/EimerNoopTracer.ts @@ -0,0 +1,35 @@ +import { EimerIdGenerator } from '../id-generator/EimerIdGenerator'; + +import { TracerBackend } from 'src/eimer'; + +import type { IdGenerator } from '../eimer/IdGenerator'; +import type { TraceContext } from '../eimer/TraceContext'; +import type { Tracer } from '../eimer/Tracer'; + +/** + * `Tracer` that does nothing (except id generation) + * Saves a bunch of if-statements wherever a tracer is optional + */ +export class EimerNoopTracer implements Tracer { + private readonly idGenerator: IdGenerator; + + constructor(idGenerator?: IdGenerator) { + this.idGenerator = idGenerator ?? new EimerIdGenerator(); + } + + setBackend(backend?: TracerBackend): void { + // no-op + } + + startSpan(_name: string, _metadata?: Record, parent?: TraceContext): TraceContext { + return { traceId: parent?.traceId ?? this.idGenerator.generateId(), spanId: this.idGenerator.generateId() }; + } + + endSpan(_tracedId?: string, _metadata?: Record): void { + // no-op + } + + shutdown(): void { + // no-op + } +} diff --git a/src/tracing/EimerPublishingTracerBackend.ts b/src/tracing/EimerPublishingTracerBackend.ts new file mode 100644 index 0000000..6f49694 --- /dev/null +++ b/src/tracing/EimerPublishingTracerBackend.ts @@ -0,0 +1,24 @@ +import type { Eimer } from '../eimer/Eimer'; +import type { Topic } from '../eimer/Topic'; +import type { TracerBackend } from '../eimer/TracerBackend'; +import type { TraceSpan } from '../eimer/TraceSpan'; + +/** + * A `TracerBackend` that sends forwards spans through through an Eimer instance + * + * This backend only *sends* spans - it should be paired with `EimerTraceSubscriber` + * elsewhere on the Eimer mesh to *receive* and process the spans. + */ +export class EimerPublishingTracerBackend implements TracerBackend { + private readonly eimer: Eimer; + private readonly topic: Topic; + + constructor(eimer: Eimer, topic: Topic = 'eimer.traces') { + this.eimer = eimer; + this.topic = topic; + } + + handleSpan(span: TraceSpan): void { + this.eimer.publish(this.topic, span); + } +} diff --git a/src/tracing/EimerTraceSubscriber.ts b/src/tracing/EimerTraceSubscriber.ts new file mode 100644 index 0000000..19b4fda --- /dev/null +++ b/src/tracing/EimerTraceSubscriber.ts @@ -0,0 +1,27 @@ +import type { Eimer } from '../eimer/Eimer'; +import type { Topic } from '../eimer/Topic'; +import type { TracerBackend } from '../eimer/TracerBackend'; +import type { TraceSpan } from '../eimer/TraceSpan'; + +/** + * The companion to `EimerPublishingTracerBackend`. + * + * Subscribes to the Eimer traces on `topic` and forwards every received + * `TraceSpan` into its configured `backend` + */ +export class EimerTraceSubscriber { + private readonly backend: TracerBackend; + private readonly unsubscribe: () => void; + + constructor(eimer: Eimer, backend: TracerBackend, topic: Topic = 'eimer.traces') { + this.backend = backend; + this.unsubscribe = eimer.subscribe(topic, (payload) => { + this.backend.handleSpan(payload as TraceSpan); + }); + } + + /** Stop receiving spans and unsubscribe from `topic`. */ + stop(): void { + this.unsubscribe(); + } +} diff --git a/src/tracing/EimerTracer.ts b/src/tracing/EimerTracer.ts new file mode 100644 index 0000000..bf97236 --- /dev/null +++ b/src/tracing/EimerTracer.ts @@ -0,0 +1,71 @@ +import { now } from './now'; +import { MemoryTracerBackend } from './MemoryTracerBackend'; + +import { EimerIdGenerator } from '../id-generator/EimerIdGenerator'; + +import type { IdGenerator } from '../eimer/IdGenerator'; +import type { TraceContext } from '../eimer/TraceContext'; +import type { Tracer } from '../eimer/Tracer'; +import type { TracerBackend } from '../eimer/TracerBackend'; +import type { TraceSpan } from '../eimer/TraceSpan'; + +/** + * A reference `Tracer` implementation. + * + * Keeps open spans in memory until they are "end"-ed and then + * forwards them to its `TracerBackend` + */ +export class EimerTracer implements Tracer { + private backend: TracerBackend; + private readonly idGenerator: IdGenerator; + private readonly openSpans: Map = new Map(); + + constructor(backend?: TracerBackend, idGenerator?: IdGenerator) { + this.backend = backend ?? new MemoryTracerBackend(); + this.idGenerator = idGenerator ?? new EimerIdGenerator(); + } + + setBackend(backend: TracerBackend): void { + this.backend = backend; + } + + startSpan(name: string, metadata?: Record, parent?: TraceContext): TraceContext { + const traceId = parent?.traceId ?? this.idGenerator.generateId(); + const spanId = this.idGenerator.generateId(); + this.openSpans.set(spanId, { + traceId, + spanId, + ...(parent?.spanId ? { parentSpanId: parent?.spanId } : {}), + name, + ...(metadata ? { metadata } : {}), + startedAt: now(), + durationMs: 0, + }); + return { traceId, spanId }; + } + + endSpan(spanId: string, metadata?: Record): void { + const openSpan = this.openSpans.get(spanId); + if (!openSpan) { + return; + } + this.openSpans.delete(spanId); + + const mergedMetadata = (openSpan.metadata || metadata) ? { ...openSpan.metadata, ...metadata } : undefined; + + const span: TraceSpan = { + traceId: openSpan.traceId, + spanId, + ...(openSpan.parentSpanId ? { parentSpanId: openSpan.parentSpanId } : {}), + name: openSpan.name, + ...(mergedMetadata ? { metadata: mergedMetadata } : {}), + startedAt: openSpan.startedAt, + durationMs: now() - openSpan.startedAt, + }; + this.backend.handleSpan(span); + } + + shutdown(): void { + this.backend.shutdown?.(); + } +} diff --git a/src/tracing/MemoryTracerBackend.ts b/src/tracing/MemoryTracerBackend.ts new file mode 100644 index 0000000..fe15afa --- /dev/null +++ b/src/tracing/MemoryTracerBackend.ts @@ -0,0 +1,23 @@ +import type { TracerBackend } from '../eimer/TracerBackend'; +import type { TraceSpan } from '../eimer/TraceSpan'; + +/** + * A `TracerBackend` that stores spans in Memory + * + * You can extract them with `getRecords` + */ +export class MemoryTracerBackend implements TracerBackend { + private readonly spans: TraceSpan[] = []; + + handleSpan(span: TraceSpan): void { + this.spans.push(span); + } + + getRecords(): TraceSpan[] { + return [...this.spans]; + } + + clear(): void { + this.spans.length = 0; + } +} diff --git a/src/tracing/index.ts b/src/tracing/index.ts new file mode 100644 index 0000000..f5f9739 --- /dev/null +++ b/src/tracing/index.ts @@ -0,0 +1,8 @@ +export * from './ConsoleLoggingTracerBackend'; +export * from './EimerLoggerTracerBackend'; +export * from './EimerPublishingTracerBackend'; +export * from './EimerTracer'; +export * from './EimerTraceSubscriber'; +export * from './MemoryTracerBackend'; +export * from './EimerNoopTracer'; +export * from './now'; diff --git a/src/tracing/now.ts b/src/tracing/now.ts new file mode 100644 index 0000000..a7605bc --- /dev/null +++ b/src/tracing/now.ts @@ -0,0 +1,10 @@ +/** + * Returns a timestamp (milliseconds since the Unix epoch) accurate enough for trace timing + * + * `Date.now()` is low-resolution and affected by adjustments of the system clock. + * + * Therefore we use `performance.now()` and add `performance.timeOrigin` to make it comparable across processes. + */ +export function now(): number { + return performance.timeOrigin + performance.now(); +} diff --git a/tests/eimer/Eimer.test.ts b/tests/eimer/Eimer.test.ts index 3b36f78..8b641a1 100644 --- a/tests/eimer/Eimer.test.ts +++ b/tests/eimer/Eimer.test.ts @@ -6,7 +6,7 @@ import { EimerLogger } from '../../src/logger'; import { EimerMessageBus } from '../../src/message-bus'; import { EimerRpcManager } from '../../src/rpc'; -import type { IdGenerator, Logger, MessageBus, MessageHandler, PayloadHandler, RpcHandler, RpcManager, Transport } from '../../src/eimer'; +import type { EimerMessageMeta, IdGenerator, Logger, MessageBus, MessageHandler, PayloadHandler, RpcHandler, RpcManager, Transport, TraceContext, Tracer } from '../../src/eimer'; describe('Eimer', () => { const nodeId = 'test-node-1'; @@ -135,7 +135,7 @@ describe('Eimer', () => { const result = await eimer.callRpc('test.method', { param: 'value' }); expect(mockRpcManager.call).toHaveBeenCalledOnce(); - expect(mockRpcManager.call).toHaveBeenCalledWith('test.method', { param: 'value' }, undefined); + expect(mockRpcManager.call).toHaveBeenCalledWith('test.method', { param: 'value' }, undefined, undefined); expect(result).toBe(expectedResult); }); @@ -151,9 +151,24 @@ describe('Eimer', () => { const result = await eimer.callRpc('test.method', { param: 'value' }, 1_000); expect(mockRpcManager.call).toHaveBeenCalledOnce(); - expect(mockRpcManager.call).toHaveBeenCalledWith('test.method', { param: 'value' }, 1_000); + expect(mockRpcManager.call).toHaveBeenCalledWith('test.method', { param: 'value' }, 1_000, undefined); expect(result).toBe(expectedResult); }); + + it('should pass optional meta through to the RPC manager', async () => { + const eimer = new Eimer(nodeId, undefined, { + rpcManager: mockRpcManager, + logger: mockLogger, + }); + + const expectedResult = { result: 'test-result' }; + (mockRpcManager.call as any).mockResolvedValue(expectedResult); + const meta = { trace: { traceId: 'trace-1', spanId: 'span-1' }, collectEimerTraces: true }; + + await eimer.callRpc('test.method', { param: 'value' }, 1_000, meta); + + expect(mockRpcManager.call).toHaveBeenCalledWith('test.method', { param: 'value' }, 1_000, meta); + }); }); describe('registerRpcHandler', () => { @@ -219,6 +234,56 @@ describe('Eimer', () => { const publishedMessage = (mockMessageBus.publish as any).mock.calls[0][0]; expect(publishedMessage.payload).toBeUndefined(); }); + + it('should not open a span when collectEimerTraces is unset', () => { + const mockTracer: Tracer = { + setBackend: vi.fn(), + startSpan: vi.fn(), + endSpan: vi.fn(), + shutdown: vi.fn(), + }; + const eimer = new Eimer(nodeId, undefined, { + idGenerator: mockIdGenerator, + messageBus: mockMessageBus, + logger: mockLogger, + tracer: mockTracer, + }); + const meta: EimerMessageMeta = { trace: { traceId: 'trace-1', spanId: 'span-1' } }; + + eimer.publish('test.topic', { data: 'test' }, meta); + + expect(mockTracer.startSpan).not.toHaveBeenCalled(); + const publishedMessage = (mockMessageBus.publish as any).mock.calls[0][0]; + expect(publishedMessage.meta.trace).toEqual({ traceId: 'trace-1', spanId: 'span-1' }); + }); + + it('should open and close a span around the publish when collectEimerTraces is set', () => { + const newTrace: TraceContext = { traceId: 'trace-1', spanId: 'span-2' }; + const mockTracer: Tracer = { + setBackend: vi.fn(), + startSpan: vi.fn().mockReturnValue(newTrace), + endSpan: vi.fn(), + shutdown: vi.fn(), + }; + const eimer = new Eimer(nodeId, undefined, { + idGenerator: mockIdGenerator, + messageBus: mockMessageBus, + logger: mockLogger, + tracer: mockTracer, + }); + const meta: EimerMessageMeta = { trace: { traceId: 'trace-1', spanId: 'span-1' }, collectEimerTraces: true }; + + eimer.publish('test.topic', { data: 'test' }, meta); + + expect(mockTracer.startSpan).toHaveBeenCalledWith( + 'test.topic', + { isEimerTrace: true, node: nodeId, component: 'Eimer', op: 'publish' }, + { traceId: 'trace-1', spanId: 'span-1' } + ); + const publishedMessage = (mockMessageBus.publish as any).mock.calls[0][0]; + expect(publishedMessage.meta.trace).toEqual(newTrace); + expect(mockTracer.endSpan).toHaveBeenCalledWith('span-2'); + }); }); describe('subscribe', () => { @@ -250,11 +315,11 @@ describe('Eimer', () => { expect(messageHandler).toBeDefined(); expect(messageHandler).toBeInstanceOf(Function); - // Verify wrapped handler calls payload handler with payload + // Verify wrapped handler calls payload handler with payload and meta const testMessage = new EimerMessage('msg-1', 'node-2', 'test.topic', { data: 'test' }); messageHandler!(testMessage); expect(payloadHandler).toHaveBeenCalledOnce(); - expect(payloadHandler).toHaveBeenCalledWith({ data: 'test' }); + expect(payloadHandler).toHaveBeenCalledWith({ data: 'test' }, undefined); }); it('should return unsubscribe function that removes handler from map', () => { @@ -297,6 +362,87 @@ describe('Eimer', () => { }); }); + describe('subscribe - trace propagation', () => { + let mockTracer: Tracer; + let startSpanCalls: Array<{ name: string; metadata?: Record; parent?: TraceContext }>; + let nextSpanId: number; + + beforeEach(() => { + startSpanCalls = []; + nextSpanId = 0; + mockTracer = { + setBackend: vi.fn(), + startSpan: vi.fn((name, metadata, parent) => { + startSpanCalls.push({ name, metadata, parent }); + return { traceId: parent?.traceId ?? 'trace-1', spanId: `span-${++nextSpanId}` }; + }), + endSpan: vi.fn(), + shutdown: vi.fn(), + }; + }); + + function createEimerWithCapturedHandlers(): { eimer: Eimer; getMessageHandler: (index: number) => MessageHandler } { + const eimer = new Eimer(nodeId, undefined, { + messageBus: mockMessageBus, + rpcManager: mockRpcManager, + logger: mockLogger, + tracer: mockTracer, + }); + return { + eimer, + getMessageHandler: (index: number) => (mockMessageBus.subscribe as any).mock.calls[index][1] as MessageHandler, + }; + } + + it('should not open a span when collectEimerTraces is unset', () => { + const { eimer, getMessageHandler } = createEimerWithCapturedHandlers(); + const payloadHandler: PayloadHandler = vi.fn(); + eimer.subscribe('test.topic', payloadHandler); + + const message = new EimerMessage('msg-1', 'node-2', 'test.topic', { data: 'test' }, { trace: { traceId: 'trace-1', spanId: 'caller-span' } }); + getMessageHandler(0)(message); + + expect(mockTracer.startSpan).not.toHaveBeenCalled(); + expect(payloadHandler).toHaveBeenCalledWith({ data: 'test' }, message.meta); + }); + + it('should open a span nested under the incoming trace and pass it to the payload handler', () => { + const { eimer, getMessageHandler } = createEimerWithCapturedHandlers(); + const payloadHandler: PayloadHandler = vi.fn(); + eimer.subscribe('test.topic', payloadHandler); + + const message = new EimerMessage('msg-1', 'node-2', 'test.topic', { data: 'test' }, { trace: { traceId: 'trace-1', spanId: 'caller-span' }, collectEimerTraces: true }); + getMessageHandler(0)(message); + + expect(startSpanCalls).toEqual([ + { name: 'test.topic', metadata: { isEimerTrace: true, node: nodeId, component: 'Eimer', op: 'handlePayload' }, parent: { traceId: 'trace-1', spanId: 'caller-span' } }, + ]); + expect(payloadHandler).toHaveBeenCalledWith({ data: 'test' }, expect.objectContaining({ + trace: { traceId: 'trace-1', spanId: 'span-1' }, + })); + expect(mockTracer.endSpan).toHaveBeenCalledWith('span-1'); + }); + + it('should not mutate message.meta.trace, so multiple subscribers on the same topic each get a sibling span instead of a chained one', () => { + const { eimer, getMessageHandler } = createEimerWithCapturedHandlers(); + const payloadHandlerA: PayloadHandler = vi.fn(); + const payloadHandlerB: PayloadHandler = vi.fn(); + eimer.subscribe('test.topic', payloadHandlerA); + eimer.subscribe('test.topic', payloadHandlerB); + + // Simulate EimerMessageBus.handleMessage fanning the same message object out to both subscribers. + const message = new EimerMessage('msg-1', 'node-2', 'test.topic', { data: 'test' }, { trace: { traceId: 'trace-1', spanId: 'caller-span' }, collectEimerTraces: true }); + getMessageHandler(0)(message); + getMessageHandler(1)(message); + + expect(startSpanCalls).toEqual([ + { name: 'test.topic', metadata: { isEimerTrace: true, node: nodeId, component: 'Eimer', op: 'handlePayload' }, parent: { traceId: 'trace-1', spanId: 'caller-span' } }, + { name: 'test.topic', metadata: { isEimerTrace: true, node: nodeId, component: 'Eimer', op: 'handlePayload' }, parent: { traceId: 'trace-1', spanId: 'caller-span' } }, + ]); + expect(message.meta?.trace).toEqual({ traceId: 'trace-1', spanId: 'caller-span' }); + }); + }); + describe('unsubscribe', () => { let eimer: Eimer; let payloadHandler: PayloadHandler; @@ -444,6 +590,25 @@ describe('Eimer', () => { eimer.shutdown(); expect(mockMessageBus.shutdown).toHaveBeenCalled(); }); + + it('should shutdown the tracer', () => { + const mockTracer: Tracer = { + setBackend: vi.fn(), + startSpan: vi.fn(), + endSpan: vi.fn(), + shutdown: vi.fn(), + }; + const eimerWithTracer = new Eimer(nodeId, undefined, { + messageBus: mockMessageBus, + rpcManager: mockRpcManager, + logger: mockLogger, + tracer: mockTracer, + }); + + eimerWithTracer.shutdown(); + + expect(mockTracer.shutdown).toHaveBeenCalledOnce(); + }); }) describe('handler mapping', () => { diff --git a/tests/eimer/EimerMessage.test.ts b/tests/eimer/EimerMessage.test.ts index b821bfa..2bf668b 100644 --- a/tests/eimer/EimerMessage.test.ts +++ b/tests/eimer/EimerMessage.test.ts @@ -22,6 +22,19 @@ describe('EimerMessage', () => { expect(message.topic).toBe('test.topic'); expect(message.payload).toEqual(payload); }); + + it('should default meta to undefined when not given', () => { + const message = new EimerMessage('msg-123', 'node-1', 'test.topic'); + + expect(message.meta).toBeUndefined(); + }); + + it('should create a message with meta', () => { + const meta = { trace: { traceId: 'trace-1', spanId: 'span-1' }, collectEimerTraces: true }; + const message = new EimerMessage('msg-123', 'node-1', 'test.topic', { data: 'test' }, meta); + + expect(message.meta).toEqual(meta); + }); }); describe('isEimerMessage', () => { diff --git a/tests/message-bus/EimerMessageBus.test.ts b/tests/message-bus/EimerMessageBus.test.ts index 1e7ef2c..aa782de 100644 --- a/tests/message-bus/EimerMessageBus.test.ts +++ b/tests/message-bus/EimerMessageBus.test.ts @@ -4,7 +4,7 @@ import { EimerMessage, Logger, LogLevel, Transport } from '../../src/eimer'; import { EimerLogger } from '../../src/logger/EimerLogger'; import { EimerMessageBus } from '../../src/message-bus'; -import type { MessageHandler } from '../../src/eimer'; +import type { MessageHandler, Tracer } from '../../src/eimer'; describe('EimerMessageBus', () => { let messageBus: EimerMessageBus; @@ -438,4 +438,99 @@ describe('EimerMessageBus', () => { }); }); }); + + describe('trace propagation on relay', () => { + let tracer: Tracer; + let startSpanCalls: Array<{ name: string; metadata?: Record; parent?: { traceId: string; spanId: string } }>; + let nextSpanId: number; + + beforeEach(() => { + startSpanCalls = []; + nextSpanId = 0; + tracer = { + setBackend: vi.fn(), + startSpan: vi.fn((name, metadata, parent) => { + startSpanCalls.push({ name, metadata, parent }); + return { traceId: parent?.traceId ?? 'trace-1', spanId: `span-${++nextSpanId}` }; + }), + endSpan: vi.fn(), + shutdown: vi.fn(), + }; + messageBus = new EimerMessageBus(nodeId, undefined, { logger: mockLogger, tracer }); + }); + + it('should not open a span when the relayed message carries no trace context', () => { + messageBus.connectTransport(mockTransport1); + messageBus.connectTransport(mockTransport2); + + const message = new EimerMessage('msg-1', 'node-2', 'test.topic'); + triggerMessageOnTransport1!(message); + + expect(tracer.startSpan).not.toHaveBeenCalled(); + expect(mockTransport2.send).toHaveBeenCalledWith(message); + }); + + it('should not open a span when collectEimerTraces is unset', () => { + messageBus.connectTransport(mockTransport1); + messageBus.connectTransport(mockTransport2); + + const message = new EimerMessage('msg-1', 'node-2', 'test.topic', undefined, { trace: { traceId: 'trace-1', spanId: 'caller-span' } }); + triggerMessageOnTransport1!(message); + + expect(tracer.startSpan).not.toHaveBeenCalled(); + expect(mockTransport2.send).toHaveBeenCalledWith(message); + }); + + it('should not open a span when there are no other transports to forward to', () => { + messageBus.connectTransport(mockTransport1); + + const message = new EimerMessage('msg-1', 'node-2', 'test.topic', undefined, { trace: { traceId: 'trace-1', spanId: 'caller-span' }, collectEimerTraces: true }); + triggerMessageOnTransport1!(message); + + expect(tracer.startSpan).not.toHaveBeenCalled(); + expect(tracer.endSpan).not.toHaveBeenCalled(); + }); + + it('should open a forward span nested under the incoming trace and attach the new spanId to the forwarded message', () => { + messageBus.connectTransport(mockTransport1); + messageBus.connectTransport(mockTransport2); + + const message = new EimerMessage('msg-1', 'node-2', 'test.topic', undefined, { trace: { traceId: 'trace-1', spanId: 'caller-span' }, collectEimerTraces: true }); + triggerMessageOnTransport1!(message); + + expect(startSpanCalls).toEqual([ + { name: 'test.topic', metadata: { isEimerTrace: true, node: nodeId, component: 'Eimer', op: 'forward' }, parent: { traceId: 'trace-1', spanId: 'caller-span' } }, + ]); + expect(mockTransport2.send).toHaveBeenCalledWith(expect.objectContaining({ + meta: { trace: { traceId: 'trace-1', spanId: 'span-1' }, collectEimerTraces: true }, + })); + expect(tracer.endSpan).toHaveBeenCalledWith('span-1'); + }); + + it('should not mutate the trace context on the original message passed to local handlers', () => { + const handler = vi.fn(); + messageBus.subscribe('test.topic', handler); + messageBus.connectTransport(mockTransport1); + messageBus.connectTransport(mockTransport2); + + const message = new EimerMessage('msg-1', 'node-2', 'test.topic', undefined, { trace: { traceId: 'trace-1', spanId: 'caller-span' }, collectEimerTraces: true }); + triggerMessageOnTransport1!(message); + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ + meta: { trace: { traceId: 'trace-1', spanId: 'caller-span' }, collectEimerTraces: true }, + })); + }); + + it('should end the forward span even if forwarding to a transport throws', () => { + (mockTransport2.send as any).mockImplementation(() => { throw new Error('boom'); }); + messageBus.connectTransport(mockTransport1); + messageBus.connectTransport(mockTransport2); + + const message = new EimerMessage('msg-1', 'node-2', 'test.topic', undefined, { trace: { traceId: 'trace-1', spanId: 'caller-span' }, collectEimerTraces: true }); + triggerMessageOnTransport1!(message); + + expect(tracer.endSpan).toHaveBeenCalledWith('span-1'); + }); + }); + }); diff --git a/tests/rpc/EimerRpcManager.test.ts b/tests/rpc/EimerRpcManager.test.ts index ed906e5..5f4871a 100644 --- a/tests/rpc/EimerRpcManager.test.ts +++ b/tests/rpc/EimerRpcManager.test.ts @@ -4,7 +4,7 @@ import { EimerMessage, LogLevel } from '../../src/eimer'; import { EimerMessageBus } from '../../src/message-bus'; import { EimerRpcManager, EimerRpcRequestMessage, EimerRpcRequestPayload, EimerRpcResponseMessage, EimerRpcResponsePayload } from '../../src/rpc'; -import type { IdGenerator, Logger, MessageBus, NodeId, RpcHandler, RpcMethod, Transport } from '../../src/eimer'; +import type { EimerMessageMeta, IdGenerator, Logger, MessageBus, NodeId, RpcHandler, RpcMethod, Transport, TraceContext, Tracer } from '../../src/eimer'; describe('EimerRpcManager', () => { let nodeId: NodeId; @@ -45,7 +45,7 @@ describe('EimerRpcManager', () => { it('should create an RPC manager with default values', () => { const rpcManager = new EimerRpcManager(nodeId, messageBus); expect(rpcManager).toBeInstanceOf(EimerRpcManager); - expect(rpcManager['timeout']).toBe(1_000); + expect(rpcManager['timeoutMs']).toBe(1_000); expect(rpcManager['idGenerator']).toBeInstanceOf(Object); expect(rpcManager['logger']).toBeInstanceOf(Object); expect(rpcManager['isSubscribedToResponses']).toBe(false); @@ -53,9 +53,9 @@ describe('EimerRpcManager', () => { it('should use custom timeout option', () => { const customTimeout = 5_000; - const rpcManager = new EimerRpcManager(nodeId, messageBus, { timeout: customTimeout }); + const rpcManager = new EimerRpcManager(nodeId, messageBus, { timeoutMs: customTimeout }); - expect(rpcManager['timeout']).toBe(customTimeout); + expect(rpcManager['timeoutMs']).toBe(customTimeout); }); it('should use custom logLevel option', () => { @@ -245,7 +245,7 @@ describe('EimerRpcManager', () => { }); it('should reject on instance-wide timeout', async () => { - const rpcManagerWithShortTimeout = new EimerRpcManager(nodeId, messageBus, { timeout: 50 }, { + const rpcManagerWithShortTimeout = new EimerRpcManager(nodeId, messageBus, { timeoutMs: 50 }, { logger: mockLogger, idGenerator: mockIdGenerator, }); @@ -399,7 +399,7 @@ describe('EimerRpcManager', () => { await new Promise(resolve => setTimeout(resolve, 2)); expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith(requestParams); + expect(handler).toHaveBeenCalledWith(requestParams, undefined); }); it('should publish success response when handler succeeds', async () => { @@ -615,7 +615,7 @@ describe('EimerRpcManager', () => { }); it('should clear timeout when response is received', async () => { - const rpcManagerWithTimeout = new EimerRpcManager(nodeId, messageBus, { timeout: 1000 }, { + const rpcManagerWithTimeout = new EimerRpcManager(nodeId, messageBus, { timeoutMs: 1000 }, { logger: mockLogger, idGenerator: mockIdGenerator, }); @@ -656,6 +656,169 @@ describe('EimerRpcManager', () => { }); }); + describe('trace propagation', () => { + let tracer: Tracer; + let startSpanCalls: Array<{ name: string; metadata?: Record; parent?: TraceContext }>; + let endSpanCalls: Array<{ spanId: string | undefined; metadata?: Record }>; + let nextSpanId: number; + let rpcManager: EimerRpcManager; + let publishedMessages: EimerMessage[]; + + beforeEach(() => { + startSpanCalls = []; + endSpanCalls = []; + nextSpanId = 0; + tracer = { + setBackend: vi.fn(), + startSpan: vi.fn((name: string, metadata?: Record, parent?: TraceContext) => { + startSpanCalls.push({ name, metadata, parent }); + return { traceId: parent?.traceId ?? 'trace-1', spanId: `span-${++nextSpanId}` }; + }), + endSpan: vi.fn((spanId?: string, metadata?: Record) => { + endSpanCalls.push({ spanId, metadata }); + }), + shutdown: vi.fn(), + }; + + rpcManager = new EimerRpcManager(nodeId, messageBus, undefined, { logger: mockLogger, idGenerator: mockIdGenerator, tracer }); + + publishedMessages = []; + const originalPublish = messageBus.publish.bind(messageBus); + vi.spyOn(messageBus, 'publish').mockImplementation((message) => { + publishedMessages.push(message); + originalPublish(message); + }); + }); + + describe('call', () => { + it('should not open a span when collectEimerTraces is unset', async () => { + const meta: EimerMessageMeta = { trace: { traceId: 'trace-1', spanId: 'caller-span' } }; + + rpcManager.call('whatever', undefined, undefined, meta); + await new Promise(resolve => setTimeout(resolve, 2)); + + expect(tracer.startSpan).not.toHaveBeenCalled(); + expect(publishedMessages[0].meta?.trace).toEqual({ traceId: 'trace-1', spanId: 'caller-span' }); + }); + + it('should open a call span nested under the given trace when collectEimerTraces is set', async () => { + const meta: EimerMessageMeta = { trace: { traceId: 'trace-1', spanId: 'caller-span' }, collectEimerTraces: true }; + + rpcManager.call('whatever.method', undefined, undefined, meta); + await new Promise(resolve => setTimeout(resolve, 2)); + + expect(startSpanCalls).toEqual([ + { name: 'whatever.method', metadata: { isEimerTrace: true, node: nodeId, component: 'EimerRpcManager', op: 'call' }, parent: { traceId: 'trace-1', spanId: 'caller-span' } }, + ]); + expect(publishedMessages[0].meta?.trace).toEqual({ traceId: 'trace-1', spanId: 'span-1' }); + }); + + it('should end the call span when the response arrives', async () => { + const meta: EimerMessageMeta = { trace: { traceId: 'trace-1', spanId: 'caller-span' }, collectEimerTraces: true }; + + const callPromise = rpcManager.call('whatever.method', undefined, undefined, meta); + await new Promise(resolve => setTimeout(resolve, 2)); + + const requestId = (publishedMessages[0] as EimerRpcRequestMessage).payload.requestId; + messageBus.publish(new EimerRpcResponseMessage( + mockIdGenerator.generateId(), + 'other-node', + EimerRpcResponseMessage.TOPIC_PREFIX, + new EimerRpcResponsePayload(requestId, undefined, 'ok') + )); + await callPromise; + + expect(endSpanCalls).toEqual([{ spanId: 'span-1', metadata: undefined }]); + }); + + it('should end the call span with eimerRpcTimeout metadata when the call times out', async () => { + const meta: EimerMessageMeta = { trace: { traceId: 'trace-1', spanId: 'caller-span' }, collectEimerTraces: true }; + + await expect(rpcManager.call('whatever.method', undefined, 10, meta)).rejects.toThrow(); + + expect(endSpanCalls).toEqual([{ spanId: 'span-1', metadata: { eimerRpcTimeout: true } }]); + }); + }); + + describe('shutdown', () => { + it('should end open call spans with eimerRpcShutdown metadata', async () => { + const meta: EimerMessageMeta = { trace: { traceId: 'trace-1', spanId: 'caller-span' }, collectEimerTraces: true }; + + const callPromise = rpcManager.call('whatever.method', undefined, undefined, meta).catch(() => {}); + rpcManager.shutdown(); + await callPromise; + + expect(endSpanCalls).toEqual([{ spanId: 'span-1', metadata: { eimerRpcShutdown: true } }]); + }); + }); + + describe('handleRequestMessage', () => { + it('should not open a span when collectEimerTraces is unset, and forward the original trace on the response', async () => { + rpcManager.registerHandler('whatever.method', async () => 'result'); + + const requestMessage = new EimerRpcRequestMessage( + mockIdGenerator.generateId(), + 'other-node', + `${EimerRpcRequestMessage.TOPIC_PREFIX}.whatever.method`, + new EimerRpcRequestPayload('req-123', 'whatever.method', {}), + { trace: { traceId: 'trace-1', spanId: 'incoming-span' } } + ); + + messageBus.publish(requestMessage); + await new Promise(resolve => setTimeout(resolve, 2)); + + expect(tracer.startSpan).not.toHaveBeenCalled(); + const response = publishedMessages.find(m => EimerRpcResponseMessage.isEimerRpcResponseMessage(m)); + expect(response?.meta?.trace).toEqual({ traceId: 'trace-1', spanId: 'incoming-span' }); + }); + + it('should open a span nested under the incoming trace, pass it to the handler, and forward it on the response', async () => { + const handler = vi.fn(async () => 'result'); + rpcManager.registerHandler('whatever.method', handler); + + const requestMessage = new EimerRpcRequestMessage( + mockIdGenerator.generateId(), + 'other-node', + `${EimerRpcRequestMessage.TOPIC_PREFIX}.whatever.method`, + new EimerRpcRequestPayload('req-123', 'whatever.method', {}), + { trace: { traceId: 'trace-1', spanId: 'incoming-span' }, collectEimerTraces: true } + ); + + messageBus.publish(requestMessage); + await new Promise(resolve => setTimeout(resolve, 2)); + + expect(startSpanCalls).toEqual([ + { name: 'whatever.method', metadata: { isEimerTrace: true, node: nodeId, component: 'EimerRpcManager', op: 'handleRequestMessage' }, parent: { traceId: 'trace-1', spanId: 'incoming-span' } }, + ]); + expect(handler).toHaveBeenCalledWith({}, expect.objectContaining({ + trace: { traceId: 'trace-1', spanId: 'span-1' }, + collectEimerTraces: true, + })); + + const response = publishedMessages.find(m => EimerRpcResponseMessage.isEimerRpcResponseMessage(m)); + expect(response?.meta?.trace).toEqual({ traceId: 'trace-1', spanId: 'span-1' }); + expect(endSpanCalls).toEqual([{ spanId: 'span-1', metadata: undefined }]); + }); + + it('should still end the span when the handler throws', async () => { + rpcManager.registerHandler('whatever.method', async () => { throw new Error('boom'); }); + + const requestMessage = new EimerRpcRequestMessage( + mockIdGenerator.generateId(), + 'other-node', + `${EimerRpcRequestMessage.TOPIC_PREFIX}.whatever.method`, + new EimerRpcRequestPayload('req-123', 'whatever.method', {}), + { trace: { traceId: 'trace-1', spanId: 'incoming-span' }, collectEimerTraces: true } + ); + + messageBus.publish(requestMessage); + await new Promise(resolve => setTimeout(resolve, 2)); + + expect(endSpanCalls).toEqual([{ spanId: 'span-1', metadata: undefined }]); + }); + }); + }); + describe('integration scenarios', () => { it('should handle full RPC call flow on same message bus', async () => { const sharedMessageBus = new EimerMessageBus('shared-node', undefined, { logger: mockLogger }); diff --git a/tests/rpc/EimerRpcRequestMessage.test.ts b/tests/rpc/EimerRpcRequestMessage.test.ts index 9344b2f..80df444 100644 --- a/tests/rpc/EimerRpcRequestMessage.test.ts +++ b/tests/rpc/EimerRpcRequestMessage.test.ts @@ -19,6 +19,14 @@ describe('EimerRpcRequestMessage', () => { expect(message.topic).toBe('eimer.rpc.request.test.method'); expect(message.payload).toEqual(payload); }); + + it('should create a message with meta', () => { + const payload = new EimerRpcRequestPayload('req-123', 'test.method', {}); + const meta = { trace: { traceId: 'trace-1', spanId: 'span-1' }, collectEimerTraces: true }; + const message = new EimerRpcRequestMessage('msg-123', 'node-1', 'eimer.rpc.request.test.method', payload, meta); + + expect(message.meta).toEqual(meta); + }); }); describe('isEimerRpcRequestMessage', () => { diff --git a/tests/rpc/EimerRpcResponseMessage.test.ts b/tests/rpc/EimerRpcResponseMessage.test.ts index c9c7e1a..f79c311 100644 --- a/tests/rpc/EimerRpcResponseMessage.test.ts +++ b/tests/rpc/EimerRpcResponseMessage.test.ts @@ -19,6 +19,14 @@ describe('EimerRpcResponseMessage', () => { expect(message.topic).toBe('eimer.rpc.response.req-123'); expect(message.payload).toEqual(payload); }); + + it('should create a message with meta', () => { + const payload = new EimerRpcResponsePayload('req-123'); + const meta = { trace: { traceId: 'trace-1', spanId: 'span-1' }, collectEimerTraces: true }; + const message = new EimerRpcResponseMessage('msg-123', 'node-1', 'eimer.rpc.response.req-123', payload, meta); + + expect(message.meta).toEqual(meta); + }); }); describe('isEimerRpcResponseMessage', () => { diff --git a/tests/tracing/ConsoleLoggingTracerBackend.test.ts b/tests/tracing/ConsoleLoggingTracerBackend.test.ts new file mode 100644 index 0000000..18f71c9 --- /dev/null +++ b/tests/tracing/ConsoleLoggingTracerBackend.test.ts @@ -0,0 +1,213 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ConsoleLoggingTracerBackend } from '../../src/tracing/ConsoleLoggingTracerBackend'; + +import type { GroupingConsole } from '../../src/tracing/ConsoleLoggingTracerBackend'; +import type { TraceSpan } from '../../src/eimer'; + +function makeSpan(overrides: Partial = {}): TraceSpan { + return { + traceId: 'trace-1', + spanId: 'span-1', + name: 'test.method', + startedAt: Date.now(), + durationMs: 5, + ...overrides, + }; +} + +function makeFakeConsole(): GroupingConsole & { calls: string[] } { + const calls: string[] = []; + return { + calls, + group: vi.fn((...args: unknown[]) => calls.push(`group:${String(args[0])}`)), + groupEnd: vi.fn(() => calls.push('groupEnd')), + log: vi.fn((...args: unknown[]) => calls.push(`log:${String(args[0])}`)), + }; +} + +describe('ConsoleLoggingTracerBackend', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('constructor', () => { + it('should default to the global console when none is injected', () => { + const groupSpy = vi.spyOn(console, 'group').mockImplementation(() => {}); + const groupEndSpy = vi.spyOn(console, 'groupEnd').mockImplementation(() => {}); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const backend = new ConsoleLoggingTracerBackend(); + + backend.handleSpan(makeSpan()); + vi.advanceTimersByTime(5_001); + + expect(groupSpy).toHaveBeenCalled(); + groupSpy.mockRestore(); + groupEndSpy.mockRestore(); + logSpy.mockRestore(); + }); + }); + + describe('handleSpan', () => { + it('should not print anything before the TTL has elapsed', () => { + const fakeConsole = makeFakeConsole(); + const backend = new ConsoleLoggingTracerBackend(undefined, fakeConsole); + + backend.handleSpan(makeSpan()); + vi.advanceTimersByTime(4_000); + + expect(fakeConsole.group).not.toHaveBeenCalled(); + }); + + it('should print the trace as a console.group after the default 5s TTL has elapsed since the last span', () => { + const fakeConsole = makeFakeConsole(); + const backend = new ConsoleLoggingTracerBackend(undefined, fakeConsole); + + backend.handleSpan(makeSpan()); + vi.advanceTimersByTime(5_001); + + expect(fakeConsole.group).toHaveBeenCalledWith(expect.stringContaining('trace-1')); + expect(fakeConsole.log).toHaveBeenCalledWith('test.method (5.00ms)', expect.any(Object)); + expect(fakeConsole.groupEnd).toHaveBeenCalledTimes(1); + }); + + it('should honor a custom ttlMs and sweepIntervalMs', () => { + const fakeConsole = makeFakeConsole(); + const backend = new ConsoleLoggingTracerBackend({ ttlMs: 100, sweepIntervalMs: 10 }, fakeConsole); + + backend.handleSpan(makeSpan()); + vi.advanceTimersByTime(101); + + expect(fakeConsole.group).toHaveBeenCalled(); + }); + + it('should reset the TTL every time a new span for the same traceId is received', () => { + const fakeConsole = makeFakeConsole(); + const backend = new ConsoleLoggingTracerBackend({ ttlMs: 100, sweepIntervalMs: 10 }, fakeConsole); + + backend.handleSpan(makeSpan({ spanId: 'span-1', startedAt: Date.now() })); + vi.advanceTimersByTime(80); + backend.handleSpan(makeSpan({ spanId: 'span-2', startedAt: Date.now() })); + vi.advanceTimersByTime(80); + + expect(fakeConsole.group).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(30); + + expect(fakeConsole.group).toHaveBeenCalled(); + }); + + it('should nest child spans under their parent via console.group', () => { + const fakeConsole = makeFakeConsole(); + const backend = new ConsoleLoggingTracerBackend({ ttlMs: 100, sweepIntervalMs: 10 }, fakeConsole); + const now = Date.now(); + + backend.handleSpan(makeSpan({ spanId: 'root', name: 'root.op', startedAt: now })); + backend.handleSpan(makeSpan({ spanId: 'child', parentSpanId: 'root', name: 'child.op', startedAt: now + 1 })); + + vi.advanceTimersByTime(120); + + expect(fakeConsole.calls).toEqual([ + expect.stringContaining('trace trace-1'), + 'group:root.op (5.00ms)', + 'log:child.op (5.00ms)', + 'groupEnd', + 'groupEnd', + ]); + }); + + it('should treat a span whose parent was never received as a root', () => { + const fakeConsole = makeFakeConsole(); + const backend = new ConsoleLoggingTracerBackend({ ttlMs: 100, sweepIntervalMs: 10 }, fakeConsole); + + backend.handleSpan(makeSpan({ spanId: 'orphan', parentSpanId: 'missing-parent', name: 'orphan.op' })); + vi.advanceTimersByTime(101); + + expect(fakeConsole.log).toHaveBeenCalledWith('orphan.op (5.00ms)', expect.any(Object)); + }); + + it('should print an empty group when every span in the trace points to another span as its parent (no root)', () => { + const fakeConsole = makeFakeConsole(); + const backend = new ConsoleLoggingTracerBackend({ ttlMs: 100, sweepIntervalMs: 10 }, fakeConsole); + + // Contrived, malformed input: two spans that are each other's parent, so neither is a root. + backend.handleSpan(makeSpan({ spanId: 'a', parentSpanId: 'b', name: 'a.op' })); + backend.handleSpan(makeSpan({ spanId: 'b', parentSpanId: 'a', name: 'b.op' })); + vi.advanceTimersByTime(101); + + expect(fakeConsole.calls).toEqual([expect.stringContaining('trace trace-1'), 'groupEnd']); + }); + + it('should keep separate traceIds in separate groups', () => { + const fakeConsole = makeFakeConsole(); + const backend = new ConsoleLoggingTracerBackend({ ttlMs: 100, sweepIntervalMs: 10 }, fakeConsole); + + backend.handleSpan(makeSpan({ traceId: 'trace-a', spanId: 'a' })); + backend.handleSpan(makeSpan({ traceId: 'trace-b', spanId: 'b' })); + vi.advanceTimersByTime(101); + + expect(fakeConsole.group).toHaveBeenCalledWith(expect.stringContaining('trace-a')); + expect(fakeConsole.group).toHaveBeenCalledWith(expect.stringContaining('trace-b')); + }); + + it('should start a fresh accumulation for a straggler span that arrives after its trace already flushed', () => { + const fakeConsole = makeFakeConsole(); + const backend = new ConsoleLoggingTracerBackend({ ttlMs: 100, sweepIntervalMs: 10 }, fakeConsole); + + backend.handleSpan(makeSpan({ spanId: 'span-1' })); + vi.advanceTimersByTime(101); + expect(fakeConsole.group).toHaveBeenCalledTimes(1); + + backend.handleSpan(makeSpan({ spanId: 'span-2', startedAt: Date.now() })); + vi.advanceTimersByTime(101); + + expect(fakeConsole.group).toHaveBeenCalledTimes(2); + }); + }); + + describe('flush', () => { + it('should do nothing when called for a traceId with no pending spans', () => { + const fakeConsole = makeFakeConsole(); + const backend = new ConsoleLoggingTracerBackend(undefined, fakeConsole); + + expect(() => backend['flush']('unknown-trace')).not.toThrow(); + expect(fakeConsole.group).not.toHaveBeenCalled(); + }); + }); + + describe('shutdown', () => { + it('should immediately print any accumulated traces', () => { + const fakeConsole = makeFakeConsole(); + const backend = new ConsoleLoggingTracerBackend(undefined, fakeConsole); + + backend.handleSpan(makeSpan()); + backend.shutdown(); + + expect(fakeConsole.group).toHaveBeenCalledWith(expect.stringContaining('trace-1')); + }); + + it('should stop the sweep timer so it does not fire again afterwards', () => { + const fakeConsole = makeFakeConsole(); + const backend = new ConsoleLoggingTracerBackend(undefined, fakeConsole); + + backend.handleSpan(makeSpan()); + backend.shutdown(); + fakeConsole.group.mockClear(); + + vi.advanceTimersByTime(60_000); + + expect(fakeConsole.group).not.toHaveBeenCalled(); + }); + + it('should be safe to call when nothing was ever recorded', () => { + const fakeConsole = makeFakeConsole(); + const backend = new ConsoleLoggingTracerBackend(undefined, fakeConsole); + + expect(() => backend.shutdown()).not.toThrow(); + }); + }); +}); diff --git a/tests/tracing/EimerLoggerTracerBackend.test.ts b/tests/tracing/EimerLoggerTracerBackend.test.ts new file mode 100644 index 0000000..b78526d --- /dev/null +++ b/tests/tracing/EimerLoggerTracerBackend.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { EimerLoggerTracerBackend } from '../../src/tracing/EimerLoggerTracerBackend'; + +import type { Logger, TraceSpan } from '../../src/eimer'; + +function makeSpan(overrides: Partial = {}): TraceSpan { + return { + traceId: 'trace-1', + spanId: 'span-1', + name: 'test.method', + startedAt: 0, + durationMs: 5, + ...overrides, + }; +} + +describe('EimerLoggerTracerBackend', () => { + describe('handleSpan', () => { + it('should log a recorded span via the injected logger', () => { + const logger: Logger = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const backend = new EimerLoggerTracerBackend(logger); + const span = makeSpan(); + + backend.handleSpan(span); + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('test.method'), span); + }); + + it('should default to an EimerLogger when none is injected', () => { + const backend = new EimerLoggerTracerBackend(); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + backend.handleSpan(makeSpan()); + + expect(infoSpy).toHaveBeenCalled(); + infoSpy.mockRestore(); + }); + }); +}); diff --git a/tests/tracing/EimerNoopTracer.test.ts b/tests/tracing/EimerNoopTracer.test.ts new file mode 100644 index 0000000..50d297e --- /dev/null +++ b/tests/tracing/EimerNoopTracer.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { EimerNoopTracer } from '../../src/tracing/EimerNoopTracer'; + +describe('EimerNoopTracer', () => { + describe('startSpan', () => { + it('should return a real, unique traceId and spanId when no parent is given', () => { + const tracer = new EimerNoopTracer(); + + const trace1 = tracer.startSpan('op'); + const trace2 = tracer.startSpan('op'); + + expect(typeof trace1.traceId).toBe('string'); + expect(typeof trace1.spanId).toBe('string'); + expect(trace1.traceId).not.toBe(trace1.spanId); + expect(trace1.traceId).not.toBe(trace2.traceId); + }); + + it('should reuse the parent traceId but still generate a fresh spanId', () => { + const tracer = new EimerNoopTracer(); + const parent = { traceId: 'trace-1', spanId: 'parent-span' }; + + const trace = tracer.startSpan('op', undefined, parent); + + expect(trace.traceId).toBe('trace-1'); + expect(trace.spanId).not.toBe('parent-span'); + }); + + it('should return a different spanId for each call, even with the same parent', () => { + const tracer = new EimerNoopTracer(); + const parent = { traceId: 'trace-1', spanId: 'parent-span' }; + + const trace1 = tracer.startSpan('op', undefined, parent); + const trace2 = tracer.startSpan('op', undefined, parent); + + expect(trace1.spanId).not.toBe(trace2.spanId); + }); + }); + + describe('endSpan', () => { + it('should do nothing and not throw', () => { + const tracer = new EimerNoopTracer(); + const trace = tracer.startSpan('op'); + + expect(() => tracer.endSpan(trace.spanId)).not.toThrow(); + expect(() => tracer.endSpan(trace.spanId, { some: 'metadata' })).not.toThrow(); + }); + }); + + describe('setBackend', () => { + it('should do nothing and not throw', () => { + const tracer = new EimerNoopTracer(); + + expect(() => tracer.setBackend({ handleSpan: () => {} })).not.toThrow(); + }); + }); + + describe('shutdown', () => { + it('should do nothing and not throw', () => { + const tracer = new EimerNoopTracer(); + + expect(() => tracer.shutdown()).not.toThrow(); + }); + }); +}); diff --git a/tests/tracing/EimerPublishingTracerBackend.test.ts b/tests/tracing/EimerPublishingTracerBackend.test.ts new file mode 100644 index 0000000..856b8a3 --- /dev/null +++ b/tests/tracing/EimerPublishingTracerBackend.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Eimer } from '../../src/eimer'; +import { EimerPublishingTracerBackend } from '../../src/tracing/EimerPublishingTracerBackend'; + +import type { Logger, MessageBus, RpcManager, TraceSpan } from '../../src/eimer'; + +function createEimer(): { eimer: Eimer; messageBus: MessageBus } { + const messageBus = { + connectTransport: vi.fn(), + disconnectTransport: vi.fn(), + publish: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + unsubscribe: vi.fn(), + unsubscribeAll: vi.fn(), + shutdown: vi.fn(), + } as MessageBus; + + const rpcManager = { + registerHandler: vi.fn(), + unregisterHandler: vi.fn(), + call: vi.fn(), + shutdown: vi.fn(), + } as RpcManager; + + const logger = { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as Logger; + + const eimer = new Eimer('node-1', undefined, { messageBus, rpcManager, logger }); + return { eimer, messageBus }; +} + +function makeSpan(overrides: Partial = {}): TraceSpan { + return { + traceId: 'trace-1', + spanId: 'span-1', + name: 'test.method', + startedAt: 0, + durationMs: 5, + ...overrides, + }; +} + +describe('EimerPublishingTracerBackend', () => { + it('should publish the span on the given topic when handleSpan is called', () => { + const { eimer, messageBus } = createEimer(); + const collector = new EimerPublishingTracerBackend(eimer, 'eimer.traces'); + const span = makeSpan(); + + collector.handleSpan(span); + + expect(messageBus.publish).toHaveBeenCalledOnce(); + const publishedMessage = (messageBus.publish as any).mock.calls[0][0]; + expect(publishedMessage.topic).toBe('eimer.traces'); + expect(publishedMessage.payload).toEqual(span); + }); + + it('should not attach a trace/collectEimerTraces to the shipping publish - this is what prevents the infinite recursion', () => { + const { eimer, messageBus } = createEimer(); + const collector = new EimerPublishingTracerBackend(eimer, 'eimer.traces'); + + collector.handleSpan(makeSpan()); + + const publishedMessage = (messageBus.publish as any).mock.calls[0][0]; + expect(publishedMessage.meta).toBeUndefined(); + }); + + it('should publish each recorded span independently', () => { + const { eimer, messageBus } = createEimer(); + const collector = new EimerPublishingTracerBackend(eimer, 'eimer.traces'); + + collector.handleSpan(makeSpan({ spanId: 'span-1' })); + collector.handleSpan(makeSpan({ spanId: 'span-2' })); + + expect(messageBus.publish).toHaveBeenCalledTimes(2); + }); + + it('should use the topic passed in, not a hardcoded default', () => { + const { eimer, messageBus } = createEimer(); + const collector = new EimerPublishingTracerBackend(eimer, 'custom.trace.topic'); + + collector.handleSpan(makeSpan()); + + const publishedMessage = (messageBus.publish as any).mock.calls[0][0]; + expect(publishedMessage.topic).toBe('custom.trace.topic'); + }); +}); diff --git a/tests/tracing/EimerTraceSubscriber.test.ts b/tests/tracing/EimerTraceSubscriber.test.ts new file mode 100644 index 0000000..5cd2654 --- /dev/null +++ b/tests/tracing/EimerTraceSubscriber.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Eimer } from '../../src/eimer'; +import { EimerTraceSubscriber } from '../../src/tracing/EimerTraceSubscriber'; + +import type { Logger, MessageBus, PayloadHandler, RpcManager, TracerBackend, TraceSpan } from '../../src/eimer'; + +function createEimer(): { eimer: Eimer; messageBus: MessageBus } { + const messageBus = { + connectTransport: vi.fn(), + disconnectTransport: vi.fn(), + publish: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + unsubscribe: vi.fn(), + unsubscribeAll: vi.fn(), + shutdown: vi.fn(), + } as MessageBus; + + const rpcManager = { + registerHandler: vi.fn(), + unregisterHandler: vi.fn(), + call: vi.fn(), + shutdown: vi.fn(), + } as RpcManager; + + const logger = { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as Logger; + + const eimer = new Eimer('node-1', undefined, { messageBus, rpcManager, logger }); + return { eimer, messageBus }; +} + +function makeSpan(overrides: Partial = {}): TraceSpan { + return { + traceId: 'trace-1', + spanId: 'span-1', + name: 'test.method', + startedAt: 0, + durationMs: 5, + ...overrides, + }; +} + +describe('EimerTraceSubscriber', () => { + it('should subscribe to "eimer.traces" by default', () => { + const { eimer, messageBus } = createEimer(); + const backend: TracerBackend = { handleSpan: vi.fn() }; + + new EimerTraceSubscriber(eimer, backend); + + expect(messageBus.subscribe).toHaveBeenCalledWith('eimer.traces', expect.any(Function)); + }); + + it('should subscribe to an explicitly given topic', () => { + const { eimer, messageBus } = createEimer(); + const backend: TracerBackend = { handleSpan: vi.fn() }; + + new EimerTraceSubscriber(eimer, backend, 'custom.topic'); + + expect(messageBus.subscribe).toHaveBeenCalledWith('custom.topic', expect.any(Function)); + }); + + it('should forward every received span into the given backend', () => { + const { eimer } = createEimer(); + const backend: TracerBackend = { handleSpan: vi.fn() }; + let payloadHandler: PayloadHandler | undefined; + vi.spyOn(eimer, 'subscribe').mockImplementation((_topic, handler) => { + payloadHandler = handler; + return vi.fn(); + }); + + new EimerTraceSubscriber(eimer, backend); + const span = makeSpan(); + payloadHandler!(span, {}); + + expect(backend.handleSpan).toHaveBeenCalledWith(span); + }); + + it('should forward each received span independently', () => { + const { eimer } = createEimer(); + const backend: TracerBackend = { handleSpan: vi.fn() }; + let payloadHandler: PayloadHandler | undefined; + vi.spyOn(eimer, 'subscribe').mockImplementation((_topic, handler) => { + payloadHandler = handler; + return vi.fn(); + }); + + new EimerTraceSubscriber(eimer, backend); + payloadHandler!(makeSpan({ spanId: 'span-1' }), {}); + payloadHandler!(makeSpan({ spanId: 'span-2' }), {}); + + expect(backend.handleSpan).toHaveBeenCalledTimes(2); + }); + + it('should unsubscribe from the topic when stop is called', () => { + const { eimer } = createEimer(); + const backend: TracerBackend = { handleSpan: vi.fn() }; + const unsubscribe = vi.fn(); + vi.spyOn(eimer, 'subscribe').mockReturnValue(unsubscribe); + + const subscriber = new EimerTraceSubscriber(eimer, backend); + subscriber.stop(); + + expect(unsubscribe).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/tracing/EimerTracer.test.ts b/tests/tracing/EimerTracer.test.ts new file mode 100644 index 0000000..3c41887 --- /dev/null +++ b/tests/tracing/EimerTracer.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { EimerTracer } from '../../src/tracing/EimerTracer'; + +import type { IdGenerator, TraceContext, TracerBackend, TraceSpan } from '../../src/eimer'; + +function createSequentialIdGenerator(): IdGenerator { + let counter = 0; + return { generateId: () => `id-${++counter}` }; +} + +function makeBackend(): TracerBackend & { handleSpan: ReturnType } { + return { handleSpan: vi.fn() }; +} + +describe('EimerTracer', () => { + describe('startSpan', () => { + it('should generate a fresh traceId and spanId when no parent is given', () => { + const tracer = new EimerTracer(undefined, createSequentialIdGenerator()); + + const trace = tracer.startSpan('op'); + + expect(trace.traceId).toBe('id-1'); + expect(trace.spanId).toBe('id-2'); + expect(trace.traceId).not.toBe(trace.spanId); + }); + + it('should return a different traceId for each call without a parent', () => { + const tracer = new EimerTracer(undefined, createSequentialIdGenerator()); + + const trace1 = tracer.startSpan('op'); + const trace2 = tracer.startSpan('op'); + + expect(trace1.traceId).not.toBe(trace2.traceId); + }); + + it('should reuse the parent traceId and create a fresh spanId when a parent is given', () => { + const tracer = new EimerTracer(undefined, createSequentialIdGenerator()); + const parent: TraceContext = { traceId: 'trace-1', spanId: 'parent-span' }; + + const trace = tracer.startSpan('op', undefined, parent); + + expect(trace.traceId).toBe('trace-1'); + expect(trace.spanId).not.toBe('parent-span'); + }); + + it('should record parentSpanId on the opened span when a parent is given', () => { + const backend = makeBackend(); + const tracer = new EimerTracer(backend, createSequentialIdGenerator()); + const parent: TraceContext = { traceId: 'trace-1', spanId: 'parent-span' }; + + const trace = tracer.startSpan('op', undefined, parent); + tracer.endSpan(trace.spanId); + + expect(backend.handleSpan).toHaveBeenCalledWith(expect.objectContaining({ parentSpanId: 'parent-span' })); + }); + + it('should omit parentSpanId on a root span', () => { + const backend = makeBackend(); + const tracer = new EimerTracer(backend, createSequentialIdGenerator()); + + const trace = tracer.startSpan('op'); + tracer.endSpan(trace.spanId); + + const recorded = backend.handleSpan.mock.calls[0][0] as TraceSpan; + expect(recorded.parentSpanId).toBeUndefined(); + }); + + it('should pass metadata through to the opened span', () => { + const backend = makeBackend(); + const tracer = new EimerTracer(backend, createSequentialIdGenerator()); + + const trace = tracer.startSpan('op', { node: 'main' }); + tracer.endSpan(trace.spanId); + + expect(backend.handleSpan).toHaveBeenCalledWith(expect.objectContaining({ metadata: { node: 'main' } })); + }); + }); + + describe('endSpan', () => { + it('should record a completed span with the given traceId/spanId/name', () => { + const backend = makeBackend(); + const tracer = new EimerTracer(backend, createSequentialIdGenerator()); + + const trace = tracer.startSpan('op'); + tracer.endSpan(trace.spanId); + + expect(backend.handleSpan).toHaveBeenCalledWith(expect.objectContaining({ + traceId: trace.traceId, + spanId: trace.spanId, + name: 'op', + })); + }); + + it('should merge metadata from startSpan and endSpan', () => { + const backend = makeBackend(); + const tracer = new EimerTracer(backend, createSequentialIdGenerator()); + + const trace = tracer.startSpan('op', { node: 'main' }); + tracer.endSpan(trace.spanId, { eimerRpcTimeout: true }); + + expect(backend.handleSpan).toHaveBeenCalledWith(expect.objectContaining({ + metadata: { node: 'main', eimerRpcTimeout: true }, + })); + }); + + it('should compute a non-negative durationMs', () => { + const backend = makeBackend(); + const tracer = new EimerTracer(backend, createSequentialIdGenerator()); + + const trace = tracer.startSpan('op'); + tracer.endSpan(trace.spanId); + + const recorded = backend.handleSpan.mock.calls[0][0] as TraceSpan; + expect(recorded.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('should do nothing when called with an unknown spanId', () => { + const backend = makeBackend(); + const tracer = new EimerTracer(backend, createSequentialIdGenerator()); + + expect(() => tracer.endSpan('unknown-span')).not.toThrow(); + expect(backend.handleSpan).not.toHaveBeenCalled(); + }); + + it('should do nothing when called twice for the same span', () => { + const backend = makeBackend(); + const tracer = new EimerTracer(backend, createSequentialIdGenerator()); + + const trace = tracer.startSpan('op'); + tracer.endSpan(trace.spanId); + tracer.endSpan(trace.spanId); + + expect(backend.handleSpan).toHaveBeenCalledOnce(); + }); + + it('should support closing spans out of order', () => { + const backend = makeBackend(); + const tracer = new EimerTracer(backend, createSequentialIdGenerator()); + + const traceA = tracer.startSpan('outer'); + const traceB = tracer.startSpan('inner'); + + tracer.endSpan(traceB.spanId); + tracer.endSpan(traceA.spanId); + + expect(backend.handleSpan).toHaveBeenCalledTimes(2); + const names = backend.handleSpan.mock.calls.map((call) => (call[0] as TraceSpan).name); + expect(names).toEqual(['inner', 'outer']); + }); + }); + + describe('setBackend', () => { + it('should allow attaching a backend after construction', () => { + const tracer = new EimerTracer(undefined, createSequentialIdGenerator()); + const backend = makeBackend(); + + const trace = tracer.startSpan('op'); + tracer.setBackend(backend); + tracer.endSpan(trace.spanId); + + expect(backend.handleSpan).toHaveBeenCalledOnce(); + }); + + it('should replace a previously configured backend', () => { + const first = makeBackend(); + const second = makeBackend(); + const tracer = new EimerTracer(first, createSequentialIdGenerator()); + + tracer.setBackend(second); + const trace = tracer.startSpan('op'); + tracer.endSpan(trace.spanId); + + expect(first.handleSpan).not.toHaveBeenCalled(); + expect(second.handleSpan).toHaveBeenCalledOnce(); + }); + }); + + describe('constructor', () => { + it('should default to a MemoryTracerBackend when none is given, so ending a span works without throwing', () => { + const tracer = new EimerTracer(undefined, createSequentialIdGenerator()); + + const trace = tracer.startSpan('op'); + + expect(() => tracer.endSpan(trace.spanId)).not.toThrow(); + }); + + it('should default to a real IdGenerator when none is given', () => { + const tracer = new EimerTracer(); + + const trace = tracer.startSpan('op'); + + expect(typeof trace.traceId).toBe('string'); + expect(trace.traceId.length).toBeGreaterThan(0); + }); + }); + + describe('shutdown', () => { + it('should call shutdown on the configured backend', () => { + const backend = { handleSpan: vi.fn(), shutdown: vi.fn() }; + const tracer = new EimerTracer(backend, createSequentialIdGenerator()); + + tracer.shutdown(); + + expect(backend.shutdown).toHaveBeenCalledOnce(); + }); + + it('should do nothing and not throw when the backend has no shutdown method', () => { + const backend = makeBackend(); + const tracer = new EimerTracer(backend, createSequentialIdGenerator()); + + expect(() => tracer.shutdown()).not.toThrow(); + }); + }); +}); diff --git a/tests/tracing/MemoryTracerBackend.test.ts b/tests/tracing/MemoryTracerBackend.test.ts new file mode 100644 index 0000000..f1f1995 --- /dev/null +++ b/tests/tracing/MemoryTracerBackend.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { MemoryTracerBackend } from '../../src/tracing/MemoryTracerBackend'; + +import type { TraceSpan } from '../../src/eimer'; + +function makeSpan(overrides: Partial = {}): TraceSpan { + return { + traceId: 'trace-1', + spanId: 'span-1', + name: 'test.method', + startedAt: Date.now(), + durationMs: 5, + ...overrides, + }; +} + +describe('MemoryTracerBackend', () => { + describe('record', () => { + it('should store a recorded span', () => { + const collector = new MemoryTracerBackend(); + const span = makeSpan(); + + collector.handleSpan(span); + + expect(collector.getRecords()).toEqual([span]); + }); + + it('should store multiple spans in recording order', () => { + const collector = new MemoryTracerBackend(); + const span1 = makeSpan({ spanId: 'span-1' }); + const span2 = makeSpan({ spanId: 'span-2' }); + + collector.handleSpan(span1); + collector.handleSpan(span2); + + expect(collector.getRecords()).toEqual([span1, span2]); + }); + + it('should store a span nested under a parent via parentSpanId', () => { + const collector = new MemoryTracerBackend(); + const span = makeSpan({ + spanId: 'span-2', + parentSpanId: 'span-1', + name: 'db.query', + }); + + collector.handleSpan(span); + + expect(collector.getRecords()).toEqual([span]); + }); + }); + + describe('getRecords', () => { + it('should return an empty array when nothing was recorded', () => { + const collector = new MemoryTracerBackend(); + expect(collector.getRecords()).toEqual([]); + }); + + it('should return a copy, not the internal array', () => { + const collector = new MemoryTracerBackend(); + collector.handleSpan(makeSpan()); + + const records = collector.getRecords(); + records.push(makeSpan({ spanId: 'span-2' })); + + expect(collector.getRecords()).toHaveLength(1); + }); + }); + + describe('clear', () => { + it('should remove all recorded spans', () => { + const collector = new MemoryTracerBackend(); + collector.handleSpan(makeSpan()); + collector.handleSpan(makeSpan({ spanId: 'span-2' })); + + collector.clear(); + + expect(collector.getRecords()).toEqual([]); + }); + }); +}); diff --git a/tests/tracing/now.test.ts b/tests/tracing/now.test.ts new file mode 100644 index 0000000..2b4cc82 --- /dev/null +++ b/tests/tracing/now.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { now } from '../../src/tracing/now'; + +describe('now', () => { + it('should return a number close to the current wall-clock time', () => { + const before = Date.now(); + const result = now(); + const after = Date.now(); + + // performance.timeOrigin + performance.now() should track Date.now() + // within a small tolerance, even though it's computed differently. + expect(result).toBeGreaterThanOrEqual(before - 5); + expect(result).toBeLessThanOrEqual(after + 5); + }); + + it('should be monotonically non-decreasing across successive calls', () => { + const a = now(); + const b = now(); + expect(b).toBeGreaterThanOrEqual(a); + }); +}); From 68b47bcb23282df532d812261b53e419c5ce2f9c Mon Sep 17 00:00:00 2001 From: Jan Hapke Date: Sat, 11 Jul 2026 03:00:47 +0200 Subject: [PATCH 2/9] fix(TS): declare unused parameter in EimerNoopTracer --- src/tracing/EimerNoopTracer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tracing/EimerNoopTracer.ts b/src/tracing/EimerNoopTracer.ts index e66b207..1cdd4aa 100644 --- a/src/tracing/EimerNoopTracer.ts +++ b/src/tracing/EimerNoopTracer.ts @@ -17,7 +17,7 @@ export class EimerNoopTracer implements Tracer { this.idGenerator = idGenerator ?? new EimerIdGenerator(); } - setBackend(backend?: TracerBackend): void { + setBackend(_backend?: TracerBackend): void { // no-op } From 07d636ebf771459b92b8ce89b3bf7abc7a48ee55 Mon Sep 17 00:00:00 2001 From: Jan Hapke Date: Sat, 11 Jul 2026 03:01:10 +0200 Subject: [PATCH 3/9] chore: updated .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 061b133..0ed502d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ coverage/ # Notes file _notes.txt +.private From 49bcec3105ff6213a6334f11268a826e9028eb86 Mon Sep 17 00:00:00 2001 From: Jan Hapke Date: Mon, 13 Jul 2026 22:55:30 +0200 Subject: [PATCH 4/9] feat(tracing): added examples/tracing --- examples/tracing/index.html | 53 ++ examples/tracing/package-lock.json | 1327 ++++++++++++++++++++++++++++ examples/tracing/package.json | 22 + examples/tracing/src/main.ts | 43 + examples/tracing/src/preload.ts | 10 + examples/tracing/src/renderer.ts | 49 + examples/tracing/src/worker.ts | 50 ++ examples/tracing/tsconfig.json | 24 + 8 files changed, 1578 insertions(+) create mode 100644 examples/tracing/index.html create mode 100644 examples/tracing/package-lock.json create mode 100644 examples/tracing/package.json create mode 100644 examples/tracing/src/main.ts create mode 100644 examples/tracing/src/preload.ts create mode 100644 examples/tracing/src/renderer.ts create mode 100644 examples/tracing/src/worker.ts create mode 100644 examples/tracing/tsconfig.json diff --git a/examples/tracing/index.html b/examples/tracing/index.html new file mode 100644 index 0000000..e18f275 --- /dev/null +++ b/examples/tracing/index.html @@ -0,0 +1,53 @@ + + + + EIMER Counter Example + + + +
+ eimer +

EIMER Counter Example

+
0
+
+ + +
+
+ + + diff --git a/examples/tracing/package-lock.json b/examples/tracing/package-lock.json new file mode 100644 index 0000000..c56bcbc --- /dev/null +++ b/examples/tracing/package-lock.json @@ -0,0 +1,1327 @@ +{ + "name": "eimer-example-worker-thread", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "eimer-example-worker-thread", + "version": "1.0.0", + "dependencies": { + "eimer": "file:../..", + "electron": "^39.2.7" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.27.1", + "typescript": "^5.0.0" + } + }, + "../..": { + "version": "0.1.0", + "devDependencies": { + "@types/node": "^20.0.0", + "@vitest/coverage-v8": "^4.0.14", + "typescript": "^5.0.0", + "vitest": "^4.0.14" + }, + "peerDependencies": { + "electron": ">=28.0.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.0.tgz", + "integrity": "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q==", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "optional": true + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT", + "optional": true + }, + "node_modules/eimer": { + "resolved": "../..", + "link": true + }, + "node_modules/electron": { + "version": "39.2.7", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.2.7.tgz", + "integrity": "sha512-KU0uFS6LSTh4aOIC3miolcbizOFP7N1M46VTYVfqIgFiuA2ilfNaOHLDS9tCMvwwHRowAsvqBrh9NgMXcTOHCQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^22.7.7", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/examples/tracing/package.json b/examples/tracing/package.json new file mode 100644 index 0000000..b320fca --- /dev/null +++ b/examples/tracing/package.json @@ -0,0 +1,22 @@ +{ + "name": "eimer-example-worker-thread", + "version": "1.0.0", + "description": "EIMER.JS basic counter example with worker thread", + "main": "dist/main.js", + "scripts": { + "start": "electron .", + "build": "tsc && npm run build:preload && npm run build:renderer", + "build:preload": "esbuild src/preload.ts --bundle --outfile=dist/preload.js --external:electron", + "build:renderer": "esbuild src/renderer.ts --bundle --outfile=dist/renderer.js", + "dev": "npm run build && electron ." + }, + "dependencies": { + "eimer": "file:../..", + "electron": "^39.2.7" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "esbuild": "^0.27.1", + "typescript": "^5.0.0" + } +} diff --git a/examples/tracing/src/main.ts b/examples/tracing/src/main.ts new file mode 100644 index 0000000..642b3ae --- /dev/null +++ b/examples/tracing/src/main.ts @@ -0,0 +1,43 @@ +import { app, BrowserWindow, ipcMain } from 'electron'; +import { Eimer, EimerIpcMainTransport, EimerWorkerThreadTransport } from 'eimer'; +import { EimerPublishingTracerBackend, EimerTracer } from 'eimer/tracing'; +import * as path from 'path'; +import { Worker } from 'node:worker_threads'; + +let mainWindow: BrowserWindow; + +// Set up main process node +const tracer = new EimerTracer(); +const eimer = new Eimer('main', {}, { tracer }); +tracer.setBackend(new EimerPublishingTracerBackend(eimer)); + +// Set up Worker Thread +const worker = new Worker(__dirname + '/worker'); + +// Connect Worker Thread to Eimer +eimer.connect(new EimerWorkerThreadTransport(worker)); + +// Start the app +app.whenReady().then(() => { + // Create main window^ + mainWindow = new BrowserWindow({ + width: 1440, + height: 600, + webPreferences: { + sandbox: true, + preload: path.join(__dirname, 'preload.js') + } + }); + + eimer.connect(new EimerIpcMainTransport(ipcMain, mainWindow.webContents)); + + // Load the renderer + mainWindow.loadFile(path.join(__dirname, '../index.html')); + mainWindow.webContents.openDevTools(); + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } + }); +}); diff --git a/examples/tracing/src/preload.ts b/examples/tracing/src/preload.ts new file mode 100644 index 0000000..475fbc2 --- /dev/null +++ b/examples/tracing/src/preload.ts @@ -0,0 +1,10 @@ +import { ipcRenderer } from 'electron'; +import { Eimer, EimerIpcRendererTransport, EimerBrowserTransport } from 'eimer'; +import { EimerPublishingTracerBackend, EimerTracer } from 'eimer/tracing'; + +// Set up eimer and connect it to the renderer via a BrowserTransport and to the main process via ipcRenderer +const tracer = new EimerTracer(); +const eimer = new Eimer('preload', {}, { tracer }); +tracer.setBackend(new EimerPublishingTracerBackend(eimer)); +eimer.connect(new EimerIpcRendererTransport(ipcRenderer)); +eimer.connect(new EimerBrowserTransport(window, { targetOrigin: '*' })); diff --git a/examples/tracing/src/renderer.ts b/examples/tracing/src/renderer.ts new file mode 100644 index 0000000..095f30e --- /dev/null +++ b/examples/tracing/src/renderer.ts @@ -0,0 +1,49 @@ +import { Eimer, EimerBrowserTransport } from 'eimer'; +import { ConsoleLoggingTracerBackend, EimerPublishingTracerBackend, EimerTracer, EimerTraceSubscriber } from 'eimer/tracing'; + +// Create eimer node and connect to "preload" via BrowserTransport +const tracer = new EimerTracer(); +const eimer = new Eimer('renderer', {}, { tracer: tracer }); +tracer.setBackend(new EimerPublishingTracerBackend(eimer)); +eimer.connect(new EimerBrowserTransport(window)); + +const traceSubscriber = new EimerTraceSubscriber(eimer, new ConsoleLoggingTracerBackend()); + +// Initialize UI and set up event handlers + +// counter value +const counterElement = document.getElementById('counter') as HTMLDivElement; +if (counterElement) { + eimer.callRpc('counter.get').then((value: number) => { counterElement.textContent = value.toString() }); + eimer.subscribe('counter.change', (value: any) => { + counterElement.textContent = value.toString(); + }); +} + +// "+" Button +const incrementButton = document.getElementById('increment') as HTMLButtonElement; +if (incrementButton) { + incrementButton.addEventListener('click', async () => { + try { + const trace = tracer.startSpan('counter.increment'); + await eimer.callRpc('counter.increment', {}, 30_000, { trace, collectEimerTraces: true }); + tracer.endSpan(trace.spanId); + } catch (error) { + console.error('Failed to increment counter:', error); + } + }); +} + +// "-" Button +const decrementButton = document.getElementById('decrement') as HTMLButtonElement; +if (decrementButton) { + decrementButton.addEventListener('click', async () => { + try { + const trace = tracer.startSpan('counter.decrement'); + await eimer.callRpc('counter.decrement', {}, 30_000, { trace }); + tracer.endSpan(trace.spanId); + } catch (error) { + console.error('Failed to decrement counter:', error); + } + }); +} diff --git a/examples/tracing/src/worker.ts b/examples/tracing/src/worker.ts new file mode 100644 index 0000000..441df5d --- /dev/null +++ b/examples/tracing/src/worker.ts @@ -0,0 +1,50 @@ +import { parentPort } from 'node:worker_threads'; +import { Eimer, EimerMessagePortTransport, MessagePort } from 'eimer'; +import { EimerPublishingTracerBackend, EimerTracer } from 'eimer/tracing'; + +const tracer = new EimerTracer(); +const eimer = new Eimer('worker', {}, { tracer }); +tracer.setBackend(new EimerPublishingTracerBackend(eimer)); +eimer.connect(new EimerMessagePortTransport(parentPort as MessagePort)); + +const sleep = async (ms:number) => { + return new Promise((resolve, _reject) => { + setTimeout(resolve, ms); + }) +}; + +// Set up counter state in worker thread +let counter = Math.round(Math.random() * 100); + +// Handle increment/decrement RPC calls +eimer.registerRpcHandler('counter.increment', async (_params, meta) => { + if (meta?.trace) { + const trace1 = tracer.startSpan('expensive increment operation 1', {}, meta.trace); + await sleep(Math.round(Math.random() * 100)); + tracer.endSpan(trace1.spanId); + + const trace2 = tracer.startSpan('expensive increment operation 2', {}, meta.trace); + await sleep(Math.round(Math.random() * 100)); + tracer.endSpan(trace2.spanId); + } + + counter += 1; + eimer.publish('counter.change', counter); + return counter; +}); + +eimer.registerRpcHandler('counter.decrement', async (_params, meta) => { + if (meta?.trace) { + const trace = tracer.startSpan('expensive decrement operation', {}, meta.trace); + await sleep(Math.round(Math.random() * 100)); + tracer.endSpan(trace.spanId); + } + counter -= 1; + eimer.publish('counter.change', counter); + return counter; +}); + +eimer.registerRpcHandler('counter.get', async () => { + console.log('counter', counter); + return counter; +}); diff --git a/examples/tracing/tsconfig.json b/examples/tracing/tsconfig.json new file mode 100644 index 0000000..e4176a8 --- /dev/null +++ b/examples/tracing/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "baseUrl": "../..", + "paths": { + "*": ["*"] + }, + "allowJs": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "ts-node": { + "esm": true, + "experimentalSpecifierResolution": "node" + }, + "include": ["src/**/*"] +} From 931c9438c652b91d4aff5846a57c5334cd82ec5a Mon Sep 17 00:00:00 2001 From: Jan Hapke Date: Mon, 13 Jul 2026 22:58:51 +0200 Subject: [PATCH 5/9] docs(tracing): updated examples/README.md --- examples/README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/examples/README.md b/examples/README.md index c8365ad..d9a73aa 100644 --- a/examples/README.md +++ b/examples/README.md @@ -152,3 +152,57 @@ npm ci npm run dev ``` The familiar window with the counter and `+` and `-` buttons should open. Clicking the buttons works as before, but you can notice in the CLI console output that the counter state and logic are handled by the worker thread. + + +## Tracing +To trace how different parts of the application perform, eimer.js provides multiple tools for distributed tracing. + +Check out [docs/TRACING.md](../docs/TRACING.md) for more details. + +How to use them is demonstrated in the [`tracing`](./tracing/) example, which builds on the worker-thread example. + +First of all, we simulate "expensive" operations by randomly sleeping inside [`worker.ts`](./tracing/worker.ts): +```typescript +const sleep = async (ms:number) => { + return new Promise((resolve, _reject) => { + setTimeout(resolve, ms); + }) +}; + +await sleep(Math.round(Math.random() * 100)); +``` + +And then, we measure how long we actually slept by instrumenting these sleep calls with `EimerTracer`: +```typescript +const trace = tracer.startSpan('expensive decrement operation', {}, meta.trace); +await sleep(Math.round(Math.random() * 100)); +tracer.endSpan(trace.spanId); +``` +Inside the worker, we attach this trace information to a parent trace we receive over eimer - and the *root* trace is started inside [`renderer.ts`](./tracing/renderer.ts): +```typescript +const trace = tracer.startSpan('counter.decrement'); +await eimer.callRpc('counter.decrement', {}, 30_000, { trace }); +``` + +We do this in both the *increment* as well as the *decrement* operation. + +To make it all work, we add a `Tracer` that uses `EimerPublishingTracerBackend` to send collected traces across eimer to *all* of our files: +```typescript +const tracer = new EimerTracer(); +const eimer = new Eimer('renderer|preload|main|worker', {}, { tracer }); +tracer.setBackend(new EimerPublishingTracerBackend(eimer)); +``` +And back in [`renderer.ts`](./tracing/renderer.ts), we place an `EimerTraceSubscriber` that prints traces as nested `console.log` groups to the developer tools console: +```typescript +const traceSubscriber = new EimerTraceSubscriber(eimer, new ConsoleLoggingTracerBackend()); +``` + +The decrement operation simply collects a trace for the entire operation as well as the simulated sleep insid the worker. The increment operation also tells eimer to collect traces about itself, so the trace will be much more detailed. +```typescript +await eimer.callRpc('counter.increment', {}, 30_000, { trace, collectEimerTraces: true }); +``` +You can run the example by executing the following commands in [`tracing`](./tracing/): +```shell +npm ci +npm run dev +``` From 82d198b652787dcf1e5787b32c781467ad7e6af7 Mon Sep 17 00:00:00 2001 From: Jan Hapke Date: Mon, 13 Jul 2026 23:15:33 +0200 Subject: [PATCH 6/9] docs(tracing): added docs/TRACING.md --- docs/TRACING.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/TRACING.md diff --git a/docs/TRACING.md b/docs/TRACING.md new file mode 100644 index 0000000..a8f165a --- /dev/null +++ b/docs/TRACING.md @@ -0,0 +1,70 @@ +# Tracing +eimer.js makes it easy to integrate functionality that is spread out across multiple processes into a single application. One thing that becomes harder with this distributed nature of an application is keeping track of performance. +When you click a button in a window that triggers work in a background process, how can you keep track of what work in the background process belonged to which button click? + +This problem is similar to how you would *trace* performance in a distributed microservices environment, consisting of multiple frontend and backend services that communicate with each other over a network. + +In distributed microservices, this problem is solved by **distributed tracing**. In its simplest form, the process or service that initiates an operation assigns it a *trace id* and passes it along with all calls that are part of that root-level operation. Each service that receives a call with a trace id can then measure how long it took to complete this operation and log this info, together with the trace id to some storage location. To make evaluation easier and more granular, these individual measurements of sub-operations are called *spans*. When the topology gets more complex, sub-operations themselves can have sub-operations, meaning spans can have sub-spans. This forms a tree, so it makes sense to store the *parent span* of every span. + +## Distributed tracing +eimer.js takes that same approach and applies it to Inter-Process-Communication. You can attach a `traceId` and a `spanId` to every message and this info is forwarded across the entire eimer.js "network" of processes. + +You can then access this info via the `meta` parameter in your message handler and use it with whatever tracing approach you are taking. +```typescript +eimer.callRpc('rpc-method', {}, 1_000, { trace: { traceId: 'a-b-c-d', spanId: '1-2-3-4' } }) +``` + +```typescript +eimer.registerRpcHandler('rpc-method', async (_params, meta) => { + console.log(meta.trace.traceId, meta.trace.spanId); +}); +``` + +## Tracing eimer itself +If you are already curious about the performance of your application and you instrument it with tracing, you are probably also curious about how much eimer itself affects this performance. And because eimer.js knows what a `traceId` and a `spanId` is, it can inject new spans around its own expensive operations relatively easily. + +Just add the `collectEimerTraces` flag to your metadata when making a call. Note that you must also specify a traceId and spanId. +```typescript +eimer.callRpc('rpc-method', {}, 1_000, { trace: { traceId: 'a-b-c-d', spanId: '1-2-3-4' }, collectEimerTraces: true }); +``` +To make this work end-to-end, every eimer.js node in your application must be able to *create new spans*. It does that through a `tracer` dependency that must fulfill the [`Tracer`](../src/eimer/Tracer.ts) interface contract. eimer.js provides a reference implementation, [`EimerTracer`](../src/tracing/EimerTracer.ts) that demonstrates how to do that but can also directly be used in real applications: +```typescript +const tracer = new EimerTracer(); +const eimer = new Eimer('node-name', {}, { tracer }); +``` +By default, `EimerTracer` just keeps spans in memory. And they are more useful if they are *logged* somewhere. A `Tracer` hands this off to a [`TracerBackend`](../src/eimer/TracerBackend.ts) to keep things composable. And because eimer.js already ships a [`Logger`](../src/eimer/Logger.ts) interface contract for logging with a reference implementation of [`EimerLogger`](../src/logger/EimerLogger.ts), it provides a reference `TracerBackend` that uses `EimerLogger` with [`EimerLoggerTracerBackend`](../src/tracing/EimerLoggerTracerBackend.ts). +```typescript +const tracer = new EimerTracer(new EimerLoggerTracerBackend()); +const eimer = new Eimer('node-name', {}, { tracer }); +``` +This setup will log all spans to the console as soon as they are completed. They include the *trace id*, *span id*, *parent span id* and measured *duration*. For a full list, see the [`TraceSpan`](../src/eimer/TraceSpan.ts) interface. + +## Collecting Traces +Logging every trace to the console is nice. But if your application has exactly the distributed multi-process setup that eimer.js makes so easy to use, it's a bit annoying to collect your traces from the console outputs of different windows or processes. + +Wouldn't it be cool to collect them in one central place? + +Fortunately, eimer.js is the perfect tool to do just that. And we only need to make 2 changes to the setup we already introduced. + +1. A `TracerBackend` that sends traces across the eimer network instead of logging it to the console +2. A `Subscriber` that collects all traces from the network and then logs them to the console. + +eimer.js ships with both. + +First, use the [`EimerPublishingTracerBackend`](../src/tracing/EimerPublishingTracerBackend.ts) to send all collected traces across eimer: +```typescript +const tracer = new EimerTracer(); +const eimer = new Eimer('node-name', {}, { tracer }); +tracer.setBackend(new EimerPublishingTracerBackend(eimer)); +``` +(note that the `EimerPublishingTracerBackend` needs a reference to an Eimer node itself, so we have to call `setBackend` *after* the Eimer object has been instantiated) + +Second, use [`EimerTraceSubscriber`](../src/tracing/EimerTraceSubscriber.ts) to collect the traces again. It supports any other `TracerBackend` to handle them. +```typescript +const traceSubscriber = new EimerTraceSubscriber(eimer, new ConsoleLoggingTracerBackend()); +``` + +The most convenient approach for this is to use the [`ConsoleLoggingTracerBackend`](../src/tracing/ConsoleLoggingTracerBackend.ts) in the renderer process - it `console.log`s the traces as nested groups into the Developer Tool's Console. + +## Example +You can find an example application that illustrates all of these concepts in [`examples/tracing`](../examples/tracing/). Check the [`README.md`](../examples/README.md) in the examples directory for how to use it. From 9b4804b3b0786657fbce2e428c3e9c525ebed7f9 Mon Sep 17 00:00:00 2001 From: Jan Hapke Date: Mon, 13 Jul 2026 23:15:44 +0200 Subject: [PATCH 7/9] docs: updated README.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 66439f1..0bb11db 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ It is written in [TypeScript](https://www.typescriptlang.org/) and comes with mi - [Add eimer.js to the Preload Script](#add-eimerjs-to-the-preload-script) - [Add eimer.js to the Browser Window](#add-eimerjs-to-the-browser-window) - [Build Process Considerations](#build-process-considerations) +- [Tracing](#tracing) - [Limitations](#limitations) - [Authors](#authors) - [License](#license) @@ -112,6 +113,11 @@ eimer.callRpc('hello').then(value => { console.log('hello ' + value) }); * `--format=cjs` * Check out the [examples](./examples) directory for complete projects including build configurations. +## Tracing +* eimer.js support forwarding of tracing information +* It ships with tooling to collect distributed tracing information across an applications entire process tree +* For details and more info, see [docs/TRACING](docs/TRACING.md) + ## Limitations This library is in pre-beta stage. From a2087b7884f56928ac8f7b7d369f985282a9eb10 Mon Sep 17 00:00:00 2001 From: Jan Hapke Date: Mon, 13 Jul 2026 23:19:03 +0200 Subject: [PATCH 8/9] docs(tracing): updated examples/README.md --- examples/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/README.md b/examples/README.md index d9a73aa..e3305c1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -141,7 +141,7 @@ const worker = new Worker(__dirname + '/worker'); eimer.connect(new EimerWorkerThreadTransport(worker)); ``` -Then, we create a [`worker.ts`](./worker-thread/worker.ts) where we paste the counter code we removed from main.ts. The only thing left is to connect the worker thread to an instance of [`Eimer`](../src/eimer/Eimer.ts). For that, we use an [`EimerMessagePortTransport`](../src/transports/EimerMessagePortTransport.ts) with the `parentPort` of our worker thread: +Then, we create a [`worker.ts`](./worker-thread/src/worker.ts) where we paste the counter code we removed from main.ts. The only thing left is to connect the worker thread to an instance of [`Eimer`](../src/eimer/Eimer.ts). For that, we use an [`EimerMessagePortTransport`](../src/transports/EimerMessagePortTransport.ts) with the `parentPort` of our worker thread: ```typescript const eimer = new Eimer('worker'); eimer.connect(new EimerMessagePortTransport(parentPort as MessagePort)); @@ -161,7 +161,7 @@ Check out [docs/TRACING.md](../docs/TRACING.md) for more details. How to use them is demonstrated in the [`tracing`](./tracing/) example, which builds on the worker-thread example. -First of all, we simulate "expensive" operations by randomly sleeping inside [`worker.ts`](./tracing/worker.ts): +First of all, we simulate "expensive" operations by randomly sleeping inside [`worker.ts`](./tracing/src/worker.ts): ```typescript const sleep = async (ms:number) => { return new Promise((resolve, _reject) => { @@ -178,7 +178,7 @@ const trace = tracer.startSpan('expensive decrement operation', {}, meta.trace); await sleep(Math.round(Math.random() * 100)); tracer.endSpan(trace.spanId); ``` -Inside the worker, we attach this trace information to a parent trace we receive over eimer - and the *root* trace is started inside [`renderer.ts`](./tracing/renderer.ts): +Inside the worker, we attach this trace information to a parent trace we receive over eimer - and the *root* trace is started inside [`renderer.ts`](./tracing/src/renderer.ts): ```typescript const trace = tracer.startSpan('counter.decrement'); await eimer.callRpc('counter.decrement', {}, 30_000, { trace }); @@ -186,18 +186,18 @@ await eimer.callRpc('counter.decrement', {}, 30_000, { trace }); We do this in both the *increment* as well as the *decrement* operation. -To make it all work, we add a `Tracer` that uses `EimerPublishingTracerBackend` to send collected traces across eimer to *all* of our files: +To make it all work, we add a `Tracer` that uses `EimerPublishingTracerBackend` to send collected traces across eimer to *all* of our processes: ```typescript const tracer = new EimerTracer(); const eimer = new Eimer('renderer|preload|main|worker', {}, { tracer }); tracer.setBackend(new EimerPublishingTracerBackend(eimer)); ``` -And back in [`renderer.ts`](./tracing/renderer.ts), we place an `EimerTraceSubscriber` that prints traces as nested `console.log` groups to the developer tools console: +And back in [`renderer.ts`](./tracing/src/renderer.ts), we place an `EimerTraceSubscriber` that prints traces as nested `console.log` groups to the developer tools console: ```typescript const traceSubscriber = new EimerTraceSubscriber(eimer, new ConsoleLoggingTracerBackend()); ``` -The decrement operation simply collects a trace for the entire operation as well as the simulated sleep insid the worker. The increment operation also tells eimer to collect traces about itself, so the trace will be much more detailed. +The decrement operation simply collects a trace for the entire operation as well as the simulated sleep inside the worker. The increment operation also tells eimer to collect traces about itself, so the trace will be much more detailed. ```typescript await eimer.callRpc('counter.increment', {}, 30_000, { trace, collectEimerTraces: true }); ``` From f4673ef7398500652a75864534a1e406b5186bfe Mon Sep 17 00:00:00 2001 From: Jan Hapke Date: Mon, 13 Jul 2026 23:28:13 +0200 Subject: [PATCH 9/9] docs: updated README.md --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0bb11db..41031ac 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ It is written in [TypeScript](https://www.typescriptlang.org/) and comes with mi ## What problem(s) does it solve? Even simple Electron applications are naturally [split into two different processes](https://www.electronjs.org/docs/latest/tutorial/process-model) (main and renderer), which have to be linked through a "preload script". Exchanging data and calling functions across these process boundaries is already non-trivial and the [interfaces provided by Electron](https://www.electronjs.org/docs/latest/tutorial/ipc) make it difficult to keep a growing code base maintainable. -As applications grow in functionality and complexity, even more processes become involved - [multiple windows](https://www.electronjs.org/docs/latest/api/base-window) on the rendering side, each potentially with [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) or iframes and the main process can start [worker threads](https://nodejs.org/api/worker_threads.html) or spawn [utility processes](https://www.electronjs.org/docs/latest/api/utility-process) which themselves can spawn more processes and threads. +As applications grow in functionality and complexity, even more processes become involved - [multiple windows](https://www.electronjs.org/docs/latest/api/base-window) on the rendering side, each potentially with [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API) or iframes; and the main process can start [worker threads](https://nodejs.org/api/worker_threads.html) or spawn [utility processes](https://www.electronjs.org/docs/latest/api/utility-process), which themselves can spawn more processes and threads. All of the different process-like concepts in Electron have slightly different APIs for inter-process communication and while [MessagePorts](https://www.electronjs.org/docs/latest/tutorial/message-ports) can be used to unify these concepts, even these are limited to point-to-point communication between at most *two* processes at a time. @@ -38,7 +38,7 @@ All of the different process-like concepts in Electron have slightly different A ## Getting Started We'll assume that you already have an Electron.js project that is written in TypeScript and it can be compiled / started. -If not, check out the [examples](./examples) directory! It also contains examples that show how to implement a [basic counter](./examples/basic-counter/) with RPC and publish/subscribe and how to extend that to [multiple windows](./examples/multi-window/) or [worker threads](./examples/worker-thread/). +If not, check out the [`examples`](./examples) directory! It also contains examples that show how to implement a [basic counter](./examples/basic-counter/) with RPC and publish/subscribe and how to extend that to [multiple windows](./examples/multi-window/) or [worker threads](./examples/worker-thread/). ### Install eimer.js ```shell @@ -74,7 +74,7 @@ app.whenReady().then(() => { ### Add eimer.js to the *Preload Script* The purpose of the [Preload Script](https://www.electronjs.org/docs/latest/tutorial/tutorial-preload) is to set up communication between the main process and the "browser" window, without exposing too many APIs of the main process in the browser context. -We set up another instance of eimer.js in the preload script, which simply forwards messages. It uses Electron's [ipcRenderer](https://www.electronjs.org/docs/latest/api/ipc-renderer) to communicate with the main process and [window.postMessage](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) to communicate with the browser window. +We set up another instance of eimer.js in the preload script, which simply forwards messages. It uses Electron's [ipcRenderer](https://www.electronjs.org/docs/latest/api/ipc-renderer) to communicate with the main process and [window.postMessage](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) to communicate with the browser window. ```typescript import { ipcRenderer } from 'electron'; import { Eimer, EimerIpcRendererTransport, EimerBrowserTransport } from 'eimer'; @@ -96,14 +96,14 @@ eimer.callRpc('hello').then(value => { console.log('hello ' + value) }); ``` ### Build Process Considerations -* The Preload Script as well as the Renderer / Browser Code run inside separate browser environments and need to be separately compiled and bundled with the eimer.js library. +* The Preload Script as well as the Renderer / Browser Code runs inside separate browser environments and needs to be separately compiled and bundled with the eimer.js library. * This is not specific to eimer.js but a general pitfall of Electron Applications * You can add extra build scripts to the `package.json` that rely on esbuild (`npm install esbuild`) ```json "scripts": { "build": "tsc && npm run build:preload && npm run build:renderer", "build:preload": "esbuild src/preload.ts --bundle --outfile=dist/preload.js --external:electron", - "build:renderer": "esbuild src/renderer.ts --bundle --outfile=dist/renderer.js", + "build:renderer": "esbuild src/renderer.ts --bundle --outfile=dist/renderer.js" } ``` * Be sure to call the `npm run build:preload` and the `npm run build:renderer` scripts in your build process! @@ -114,8 +114,8 @@ eimer.callRpc('hello').then(value => { console.log('hello ' + value) }); * Check out the [examples](./examples) directory for complete projects including build configurations. ## Tracing -* eimer.js support forwarding of tracing information -* It ships with tooling to collect distributed tracing information across an applications entire process tree +* eimer.js supports forwarding of tracing information +* It ships with tooling to collect distributed tracing information across an application's entire process tree * For details and more info, see [docs/TRACING](docs/TRACING.md) ## Limitations @@ -123,7 +123,7 @@ This library is in pre-beta stage. Existing functionality mostly works and is well tested. However, performance is not optimized at all (for example, all messages are broadcast to all connected processes, the ids of all messages ever sent -is kept in memory indefinitely) and features are limited (security and extensibility need improvement). +are kept in memory indefinitely) and features are limited (security and extensibility need improvement). Also the documentation is lacking.