Skip to content

@studnicky/timing

High-resolution timing tracker for collecting operation metrics.

Install

bash
pnpm add @studnicky/timing

Usage

Build a Timing instance with the builder, then record component.operation and component.operation.status events. Elapsed milliseconds are collected in a flat map keyed by event name:

ts
import { Timing, TIMING_STATUS, TimingEvent } from '../src/index.js';

const timing = Timing.builder().maxEvents(50).build();

// Record a plain component.operation event
timing.event(
  TimingEvent.create().component('GraphAdapter').operation('query').build()
);

// Record component.operation.status events
timing.event(
  TimingEvent.create()
    .component('CacheService')
    .operation('get')
    .status(TIMING_STATUS.START)
    .build()
);

timing.event(
  TimingEvent.create()
    .component('CacheService')
    .operation('get')
    .status(TIMING_STATUS.COMPLETE)
    .build()
);

timing.event(
  TimingEvent.create()
    .component('CacheService')
    .operation('get')
    .status(TIMING_STATUS.HIT)
    .build()
);

const events = timing.getEvents();

console.log('events:', events);

No-op for production disabling

NoOpTiming implements the same interface with zero overhead; all calls are accepted and discarded:

ts
import { NoOpTiming, TimingEvent } from '../src/index.js';

const timing = NoOpTiming.create();

// Recording events is accepted without error but produces no stored data
timing.event(TimingEvent.create().component('Cache').operation('get').build());
timing.event(TimingEvent.create().component('Cache').operation('set').build());

const events = timing.getEvents();

// clear() returns the same instance for method chaining
const returned = timing.clear();

console.log('NoOpTiming.getEvents():', events);
console.log('clear() returns self:', returned === timing);

Try it

Builder

Timing.builder().maxEvents(50).build() constructs the tracker. Press Execute to record a GraphAdapter.query event plus three CacheService.get events with start, complete, and hit statuses. The output map shows each event key with its elapsed-milliseconds value relative to instance creation.

Basic timing — builder, events, elapsed-ms output
/** basic-usage — build a Timing instance, record component.operation events, and inspect elapsed-ms output. Run: npx tsx examples/basic-usage.ts */

import assert from 'node:assert/strict';

// #region usage
import { Timing, TIMING_STATUS, TimingEvent } from '../src/index.js';

const timing = Timing.builder().maxEvents(50).build();

// Record a plain component.operation event
timing.event(
  TimingEvent.create().component('GraphAdapter').operation('query').build()
);

// Record component.operation.status events
timing.event(
  TimingEvent.create()
    .component('CacheService')
    .operation('get')
    .status(TIMING_STATUS.START)
    .build()
);

timing.event(
  TimingEvent.create()
    .component('CacheService')
    .operation('get')
    .status(TIMING_STATUS.COMPLETE)
    .build()
);

timing.event(
  TimingEvent.create()
    .component('CacheService')
    .operation('get')
    .status(TIMING_STATUS.HIT)
    .build()
);

const events = timing.getEvents();

console.log('events:', events);
// #endregion usage

// Structural assertions — deterministic regardless of wall-clock timing
assert.ok('initialize' in events, 'initialize key must be present');
assert.ok('GraphAdapter.query' in events, 'GraphAdapter.query key must be present');
assert.ok('CacheService.get.start' in events, 'CacheService.get.start key must be present');
assert.ok('CacheService.get.complete' in events, 'CacheService.get.complete key must be present');
assert.ok('CacheService.get.hit' in events, 'CacheService.get.hit key must be present');
assert.ok('durationMs' in events, 'durationMs key must be present');

assert.equal(typeof events['GraphAdapter.query'], 'number');
assert.equal(typeof events['CacheService.get.start'], 'number');
assert.ok(events['GraphAdapter.query'] >= 0);
assert.ok(events.durationMs >= 0);

console.log('basic-usage: all assertions passed');
Output
Press Execute to run this example against the real library.

Lifecycle hooks

ObservedTiming subclasses Timing and overrides five hooks: onInitialize, onEvent, onEvict, onClear, and onGetEvents. With maxEvents=3 the cache holds three entries; the fourth event triggers onEvict for the oldest. Watch the hook trace print for every operation, including the two getEvents() calls and the single clear().

Observed timing — lifecycle hook trace
/** observedTiming — subclass Timing to trace every lifecycle hook. Run: npx tsx examples/observedTiming.ts */

import assert from 'node:assert/strict';

// #region usage
import type { TimingEventDataEntity, TimingOptionsEntity } from '../src/index.js';

import { Timing, TimingEvent } from '../src/index.js';

class ObservedTiming extends Timing {
  // onInitialize fires inside super() before class field initializers run.
  // Use `declare` so TypeScript knows the type but emits no own-property
  // initializer that would reset the value after super() returns.
  declare initEvents: { 'startTime': bigint }[];
  recordedEvents: { 'data': TimingEventDataEntity.Type; 'timestamp': bigint }[] = [];
  evictedNames: string[] = [];
  clearCount = 0;
  getEventsCalls: { 'eventCount': number }[] = [];

  public constructor(options: TimingOptionsEntity.Type = {}) {
    super(options);
  }

  protected override onInitialize(startTime: bigint): void {
    console.log(`[timing] initialize startTime=${startTime}`);
    // Bootstrap the array here because this fires before the field initializer.
    this.initEvents ??= [];
    this.initEvents.push({ 'startTime': startTime });
  }

  protected override onEvent(data: TimingEventDataEntity.Type, timestamp: bigint): void {
    console.log(`[timing] event name=${data.event} timestamp=${timestamp}`);
    this.recordedEvents.push({ 'data': data, 'timestamp': timestamp });
  }

  protected override onEvict(name: string): void {
    console.log(`[timing] evict name=${name}`);
    this.evictedNames.push(name);
  }

  protected override onClear(): void {
    console.log('[timing] clear');
    this.clearCount++;
  }

  protected override onGetEvents(eventCount: number): void {
    console.log(`[timing] getEvents eventCount=${eventCount}`);
    this.getEventsCalls.push({ 'eventCount': eventCount });
  }
}

// Create an ObservedTiming with a small maxEvents to trigger eviction
const timing = new ObservedTiming({ 'maxEvents': 3 });

// Record two events (cache: initialize + DbAdapter.query + CacheService.get = 3, at capacity)
timing.event(
  TimingEvent.create().component('DbAdapter').operation('query').build()
);

timing.event(
  TimingEvent.create().component('CacheService').operation('get').build()
);

// Call getEvents to trigger onGetEvents (3 entries in cache)
const snapshot = timing.getEvents();
console.log('snapshot keys:', Object.keys(snapshot));

// Clear to trigger onClear
timing.clear();

// Fill cache to capacity (maxEvents: 3) then overflow to trigger eviction
timing.event(
  TimingEvent.create().component('DbAdapter').operation('insert').build()
);

timing.event(
  TimingEvent.create().component('CacheService').operation('set').build()
);

timing.event(
  TimingEvent.create().component('MetricsService').operation('flush').build()
);

// This 4th event overflows the cache — evicts DbAdapter.insert
timing.event(
  TimingEvent.create().component('MetricsService').operation('emit').build()
);

// Final getEvents call
const final = timing.getEvents();
console.log('final keys:', Object.keys(final));
// #endregion usage

// Assertions on recorded events structure
assert.equal(timing.initEvents.length, 1, 'onInitialize should fire exactly once');
assert.equal(typeof timing.initEvents[0]!.startTime, 'bigint', 'startTime should be bigint');

assert.ok(timing.recordedEvents.length >= 4, 'at least 4 events should have been recorded');

for (const entry of timing.recordedEvents) {
  assert.equal(typeof entry.data.event, 'string', 'event label should be a string');
  assert.equal(typeof entry.timestamp, 'bigint', 'timestamp should be bigint');
}

assert.ok(timing.evictedNames.length >= 1, 'at least one eviction should have occurred');
assert.equal(timing.clearCount, 1, 'onClear should fire exactly once');
assert.equal(timing.getEventsCalls.length, 2, 'onGetEvents should fire twice');

for (const call of timing.getEventsCalls) {
  assert.equal(typeof call.eventCount, 'number', 'eventCount should be a number');
}

console.log('observedTiming: all assertions passed');
Output
Press Execute to run this example against the real library.

Subpath exports

SubpathContents
@studnicky/timingTiming, TimingEvent, NoOpTiming, TIMING_STATUS, ConfigurationError, TimingBuildError
@studnicky/timing/buildersBuilder classes
@studnicky/timing/constantsTIMING_STATUS
@studnicky/timing/errorsError classes
@studnicky/timing/interfacesInterface types
@studnicky/timing/typesType aliases

Extending

Timing exposes five protected observability hooks. Subclass and override them to intercept events for metrics export, structured logging, or test assertions:

ts
import { Timing, TimingEvent } from '../src/index.js';

class InstrumentedTiming extends Timing {
  readonly fired: string[] = [];

  protected override onEvent(data: { 'event': string }): void {
    this.fired.push(data.event);
  }

  static of(): InstrumentedTiming {
    return new InstrumentedTiming({});
  }
}

const timing = InstrumentedTiming.of();

timing.event(
  TimingEvent.create().component('GraphAdapter').operation('query').build()
);

timing.event(
  TimingEvent.create().component('AuthService').operation('verify').build()
);

console.log('fired:', timing.fired);

Observability hooks

HookWhen it firesArgs
onInitializeAfter the instance is fully initializedstartTime: bigint
onEventAfter an event is added to the cachedata: TimingEventDataType, timestamp: bigint
onEvictBefore an event is evicted from the cachename: string
onClearBefore the cache is cleared(none)
onGetEventsAt the start of each getEvents() calleventCount: number
ts
import type { TimingEventDataEntity, TimingOptionsEntity } from '../src/index.js';

import { Timing, TimingEvent } from '../src/index.js';

class ObservedTiming extends Timing {
  // onInitialize fires inside super() before class field initializers run.
  // Use `declare` so TypeScript knows the type but emits no own-property
  // initializer that would reset the value after super() returns.
  declare initEvents: { 'startTime': bigint }[];
  recordedEvents: { 'data': TimingEventDataEntity.Type; 'timestamp': bigint }[] = [];
  evictedNames: string[] = [];
  clearCount = 0;
  getEventsCalls: { 'eventCount': number }[] = [];

  public constructor(options: TimingOptionsEntity.Type = {}) {
    super(options);
  }

  protected override onInitialize(startTime: bigint): void {
    console.log(`[timing] initialize startTime=${startTime}`);
    // Bootstrap the array here because this fires before the field initializer.
    this.initEvents ??= [];
    this.initEvents.push({ 'startTime': startTime });
  }

  protected override onEvent(data: TimingEventDataEntity.Type, timestamp: bigint): void {
    console.log(`[timing] event name=${data.event} timestamp=${timestamp}`);
    this.recordedEvents.push({ 'data': data, 'timestamp': timestamp });
  }

  protected override onEvict(name: string): void {
    console.log(`[timing] evict name=${name}`);
    this.evictedNames.push(name);
  }

  protected override onClear(): void {
    console.log('[timing] clear');
    this.clearCount++;
  }

  protected override onGetEvents(eventCount: number): void {
    console.log(`[timing] getEvents eventCount=${eventCount}`);
    this.getEventsCalls.push({ 'eventCount': eventCount });
  }
}

// Create an ObservedTiming with a small maxEvents to trigger eviction
const timing = new ObservedTiming({ 'maxEvents': 3 });

// Record two events (cache: initialize + DbAdapter.query + CacheService.get = 3, at capacity)
timing.event(
  TimingEvent.create().component('DbAdapter').operation('query').build()
);

timing.event(
  TimingEvent.create().component('CacheService').operation('get').build()
);

// Call getEvents to trigger onGetEvents (3 entries in cache)
const snapshot = timing.getEvents();
console.log('snapshot keys:', Object.keys(snapshot));

// Clear to trigger onClear
timing.clear();

// Fill cache to capacity (maxEvents: 3) then overflow to trigger eviction
timing.event(
  TimingEvent.create().component('DbAdapter').operation('insert').build()
);

timing.event(
  TimingEvent.create().component('CacheService').operation('set').build()
);

timing.event(
  TimingEvent.create().component('MetricsService').operation('flush').build()
);

// This 4th event overflows the cache — evicts DbAdapter.insert
timing.event(
  TimingEvent.create().component('MetricsService').operation('emit').build()
);

// Final getEvents call
const final = timing.getEvents();
console.log('final keys:', Object.keys(final));

The base class never calls any logger or metrics library. All hooks are no-ops by default.

Source on GitHub