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