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(),
    };
  },
});