@studnicky/timing
High-resolution timing tracker for collecting operation metrics.
Install
pnpm add @studnicky/timingUsage
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:
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:
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-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');
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().
/** 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');
Subpath exports
| Subpath | Contents |
|---|---|
@studnicky/timing | Timing, TimingEvent, NoOpTiming, TIMING_STATUS, ConfigurationError, TimingBuildError |
@studnicky/timing/builders | Builder classes |
@studnicky/timing/constants | TIMING_STATUS |
@studnicky/timing/errors | Error classes |
@studnicky/timing/interfaces | Interface types |
@studnicky/timing/types | Type aliases |
Extending
Timing exposes five protected observability hooks. Subclass and override them to intercept events for metrics export, structured logging, or test assertions:
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
| Hook | When it fires | Args |
|---|---|---|
onInitialize | After the instance is fully initialized | startTime: bigint |
onEvent | After an event is added to the cache | data: TimingEventDataType, timestamp: bigint |
onEvict | Before an event is evicted from the cache | name: string |
onClear | Before the cache is cleared | (none) |
onGetEvents | At the start of each getEvents() call | eventCount: number |
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.