Telemetry Experimental
Headless, signals-native telemetry for Angular. One facade for spans, events, errors, metrics, and logs, sent to capability-based sinks. Context propagation is explicit and zone-free, and with no configuration the injected facade is a noop that costs effectively nothing.
@mmstack/telemetry-corenpm
This API surface is experimental: it may still change and is not yet battle-tested in production. Pin a version and expect some churn.
Angular has been zoneless by default since v21. OpenTelemetry's browser context propagation still relies on zone.js, which also breaks on native async/await, and there is no built-in zoneless context manager and no official Angular instrumentation. If you want real traces in a modern Angular app, ambient context is a dead end.
This suite answers that with explicit context. Spans parent through values you already hold: a handle you pass, an HttpContext on a request, the injector tree for component lineage. It is a telemetry library first, not an OpenTelemetry library. The trace representation is OTLP shaped, so export and cross-vendor correlation come for free, but events, errors, metrics, and logs stay first-class instead of being forced through a trace model.
npm install @mmstack/telemetry-core#Setup
provideTelemetry is opt-in. With no valid sink the injected TELEMETRY is a noop, so an app that ships no telemetry config pays nothing. Add an adapter for your backend (see Adapters) and the interceptor for automatic HTTP spans.
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideTelemetry, telemetryInterceptor } from '@mmstack/telemetry-core';
import { otelSink } from '@mmstack/telemetry-otel';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([telemetryInterceptor])),
provideTelemetry({
sinks: [otelSink({ endpoint: 'https://collector.example.com' })],
}),
],
};#The facade
Inject TELEMETRY and call span, event, error, metric, or log. Anything emitted inside a span body carries that span's trace_id and span_id automatically, through a synchronous active-span stack. A nested span joins the active trace; pass { parent: null } to start a fresh root.
import { inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { TELEMETRY, withTelemetryParent } from '@mmstack/telemetry-core';
export class CheckoutComponent {
private readonly telemetry = inject(TELEMETRY);
private readonly http = inject(HttpClient);
submit() {
// the body returns a promise, so the span stays open until it settles
return this.telemetry.span('checkout', async (span) => {
this.telemetry.event('checkout.started', { plan: 'pro' });
await firstValueFrom(
this.http.post('/api/orders', body, { context: withTelemetryParent(span) }),
);
});
}
} Emits are attributes-first: event(name, attrs?, opt?), and the same shape for error, metric, and log. The optional last argument carries the consent category and an explicit parent span.
#Capability sinks
A sink implements only what it supports: SpanSink, EventSink, ErrorSink, MetricSink, LogSink, IdentitySink. The facade routes each emit to the sinks that can handle it and shares one trace context across all of them, so a PostHog event and a Sentry error both correlate to the same OTLP trace. A sink declares readiness as a signal, so while it initializes the facade buffers, then flushes in order once it is ready, or drops the buffer after a timeout so a broken sink cannot grow memory.
For tests, memorySink() records everything a real sink would receive, so you can assert your instrumentation with plain unit tests.
#Identity and super-properties
identify(userId, traits?) associates subsequent telemetry with a user on every sink that has an identity concept (a PostHog person, a Sentry user), and identify(null) clears it on logout. It is a no-op on sinks without one (OTel has no user primitive; attribute users there with a global attribute instead), so the call is always safe.
setGlobalAttrs(attrs) sets super-properties: attributes merged into every subsequent emit (event, error, metric, log, and span), so you set context like the active tenant or tool once rather than on every call. Calls accumulate; a key set to undefined removes it. Traits and global attributes both run through your AttributePolicy.
const telemetry = inject(TELEMETRY);
// on login: attaches to the PostHog person, the Sentry user, …
telemetry.identify('user-42', { plan: 'pro' });
// super-properties ride on every subsequent emit, no repetition
telemetry.setGlobalAttrs({ tenant: 'acme', tool: 'etl' });
telemetry.event('export.started'); // carries tenant + tool automatically
telemetry.setGlobalAttrs({ tool: undefined }); // remove one
telemetry.identify(null); // logout#HTTP spans
telemetryInterceptor records a span per request: method, host and path, status, and duration. It never records the query string or the body. By default each request span is its own flat trace. To nest a request under a caller span, stamp the active span onto the request with withTelemetryParent.
// nests the request span under the active caller span
this.http.post('/api/orders', body, {
context: withTelemetryParent(span),
});#Signal causality
tracedSignal records the span active at each write, so a downstream reactive consumer (a refetch, a derived recompute) can attribute itself to the interaction that caused it. Capture is synchronous: a write after an await does not attribute, and reading causedBy() never creates a reactive dependency.
import { tracedSignal } from '@mmstack/telemetry-core';
const quantity = tracedSignal(1);
telemetry.span('add-item', () => quantity.update((q) => q + 1));
quantity.causedBy(); // the 'add-item' span#Privacy is a mechanism, not a policy
The core sends the attributes you pass. Policy is yours, per sink: an AttributePolicy transforms attributes before a sink receives them, with builders to compose (allowOnly, deny, redactKeys, hashKeys, compose). Send rich attributes to your own sink and a redacted subset to a vendor. The one place the library forms its own attributes, the HTTP interceptor, defaults conservatively, and your policy still applies on top.
import { allowOnly, provideTelemetry } from '@mmstack/telemetry-core';
provideTelemetry({
sinks: [vendorSink, internalSink],
policy: (attrs, meta) =>
meta.sink === 'vendor' ? allowOnly(['plan', 'step'])(attrs, meta) : attrs,
});