Observability
An optional listener seam on the concurrency layer. Install a listener and you receive events as transition scopes coordinate pending, suspense, and transaction windows. Install nothing and the taps compile to no-ops, so there is no cost when it is off.
@mmstack/primitivesnpm
Component devtools tell you what re-rendered. They cannot easily tell you why a boundary was pending or how long a transaction held. A transition scope knows both, because it is the thing coordinating the async state, so it is the right place to observe from.
#The listener
ConcurrencyInstrumentation is a set of optional hooks. The window-shaped ones (pendingStart/pendingEnd, transactionStart/transactionEnd) return a handle passed back to their end, and carry a timestamp. That shape is deliberately the same as a telemetry span, so forwarding to @mmstack/telemetry-core is a direct mapping. The event-shaped ones report resource registration and abort.
import {
provideConcurrencyInstrumentation,
type ConcurrencyInstrumentation,
} from '@mmstack/primitives';
const listener: ConcurrencyInstrumentation = {
pendingStart: (e) => ({ scope: e.scope, at: e.at }),
pendingEnd: (handle, e) => report(handle, e.at),
resourceRegistered: (e) => count(e.scope, +1),
abortPending: (e) => log('aborted', e.aborted, 'in', e.scope),
};
// at the app or a boundary
providers: [provideConcurrencyInstrumentation(listener)];#DevTools performance tracks
perfCustomTracks() is a ready listener that writes a performance.measure for each pending and transaction window onto a custom track in the Chrome DevTools Performance panel. It has no dependencies and no backend, so it is a dev-only way to see reactive coordination on the same timeline as everything else the browser records.
import { perfCustomTracks, provideConcurrencyInstrumentation } from '@mmstack/primitives';
providers: [provideConcurrencyInstrumentation(perfCustomTracks())];
// pending and transaction windows now appear on an "mmstack" track in the
// DevTools Performance panel#Forwarding to telemetry
Because the window hooks are span-shaped, a listener that binds them to an injected TELEMETRY facade is a few lines. Every event can carry a category, so consent gates the whole subsystem with one requirement.
import { inject } from '@angular/core';
import { TELEMETRY } from '@mmstack/telemetry-core';
import {
CONCURRENCY_INSTRUMENTATION,
type ConcurrencyInstrumentation,
} from '@mmstack/primitives';
// resolve the facade once at provide time, then close over it in the hooks
export const provideConcurrencyTelemetry = () => ({
provide: CONCURRENCY_INSTRUMENTATION,
useFactory: (): ConcurrencyInstrumentation => {
const telemetry = inject(TELEMETRY);
return {
pendingStart: (e) =>
telemetry.startSpan('mm.pending', { attrs: { scope: e.scope }, category: 'perf' }),
pendingEnd: (span) => (span as { end(): void }).end(),
};
},
});