Skip to content

nais/apm

Repository files navigation

@nais/apm

A drop-in browser telemetry SDK for applications running on nais. @nais/apm wraps @grafana/faro-web-sdk with a Sentry-like developer experience — zero-config initialization on nais, captureException/captureMessage/setUser-style APIs, mandatory PII scrubbing, a fixed console-capture bug, and opt-in session replay / crash snapshots — and ships everything to your team's self-hosted Grafana LGTM stack (Alloy faro.receiver → Loki/Tempo/Mimir) instead of a third-party SaaS.

Status: 0.1.0, pre-release. This is the first published version, intended for early external testing. The public API may still change before 1.0 — pin an exact version and expect breaking changes to land in minor releases until then. Feedback and issues are very welcome: nais/apm/issues.

Install

@nais/apm is published to the GitHub Package Registry (GHPR) under the nais org, not to npmjs.org (yet).

GHPR requires an authenticated request to resolve any package under a scope — including public ones — so even though @nais/apm itself is public, installing it needs a GitHub Personal Access Token with the read:packages scope. This is a one-time setup per machine/CI job.

  1. Create a classic PAT with the read:packages scope (fine-grained tokens work too, as long as they can read packages for the nais org).

  2. Add these two lines to your project's .npmrc (create the file if it doesn't exist):

    @nais:registry=https://npm.pkg.github.com
    //npm.pkg.github.com/:_authToken=${GITHUB_PACKAGES_TOKEN}
  3. Export the token in your shell (or CI secret store) as GITHUB_PACKAGES_TOKEN:

    export GITHUB_PACKAGES_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  4. Install:

    pnpm add @nais/apm
    # or: npm install @nais/apm / yarn add @nais/apm

This friction is a GHPR limitation, not a @nais/apm design choice. A future move to npmjs.org (npmjs.com/package/@nais/apm) would let anyone npm install @nais/apm with no token at all — it's on the roadmap once the package is stable.

Quickstart

// main.tsx — the whole zero-config story
import { init } from '@nais/apm';

init(); // app name, version, environment and collector URL resolved from nais
// Sentry-style usage anywhere in your app
import { captureException, captureMessage, setUser, setContext } from '@nais/apm';

try {
  await save(form);
} catch (e) {
  captureException(e, { context: { form: 'checkout-step-2' } });
}

captureMessage('fallback flow used', 'warning');
setUser({ id: hashedSubject });
setContext('feature', { newCheckoutFlow: 'variant-b' });

That's it on nais: init() resolves app name, version, environment, and the collector URL automatically (see Configuration resolution below). Off nais — e.g. on localhost — nothing is sent over the network; every signal is echoed to the browser console instead (see Local development).

API reference

init(options?)

Initializes the SDK. Safe to call once; a second call warns and returns the existing instance.

import { init } from '@nais/apm';

init({
  // Each field resolves independently if omitted (see below). `namespace` (the
  // owning nais team) is effectively required — telemetry without it can't be
  // attributed to a team; if it can't be resolved the SDK loud-warns and falls
  // back to `unknown-team` (it never throws).
  app: 'my-app',
  namespace: 'my-team',
  version: '2026.07.04-abc1234',
  environment: 'prod-gcp',
  telemetryUrl: undefined, // usually omitted — resolved automatically on nais

  beforeSend: (item) => item, // runs before the mandatory PII scrubber
  ignoreErrors: [/some noisy vendor error/],
  dangerouslyDisablePiiScrubbing: false, // see Privacy section — don't flip this without a reason
  faro: {}, // escape hatch: raw Faro BrowserConfig overrides

  sessionReplay: { enabled: false }, // see Session replay & crash snapshots
  screenshotOnError: false,
});

Configuration resolution

Each field (app, namespace, version, environment, telemetryUrl) resolves independently, highest priority first:

  1. Explicit init() options.
  2. nais meta tags in the served HTML:
    <meta name="nais-app" content="my-app" />
    <meta name="nais-team" content="my-team" />
    <meta name="nais-cluster" content="prod-gcp" />
    <meta name="nais-version" content="2026.07.03-abc1234" />
    <meta name="nais-telemetry-url" content="https://telemetry.<tenant>.example/collect" />
    In practice this tag is injected by the nais platform, not written by hand.
  3. Build-time environment variablesNAIS_APP_NAME, NAIS_TEAM (or NAIS_NAMESPACE), NAIS_CLUSTER_NAME, and a version derived from NAIS_APP_IMAGE's tag (or GITHUB_SHA if set). These only work when your bundler inlines process.env.* (webpack DefinePlugin, Vite define, Next.js env).
  4. Collector fallback — with no explicit/meta collector URL, a well-known collector is derived from the cluster name as a last resort. This fallback currently assumes the nav tenant; other tenants should rely on the meta tag/env, which the platform sets automatically.
  5. Dev mode — if no collector URL resolves at all (typically localhost), nothing is sent; see Local development.

The namespace (owning nais team) is special: the plugin groups and attributes all telemetry by team, so browser telemetry without it can't be reliably attributed. It resolves via the same precedence (init({ namespace })nais-team/nais-namespace meta → NAIS_TEAM/NAIS_NAMESPACE env) and is wired to Faro's app.namespace, which the collector emits as the app_namespace log field. If it can't be resolved the SDK does not throw (that would take down your app) — it loud-warns (console.error in prod, console.warn in dev) and falls back to unknown-team.

captureException(error, options?)

Sentry-compatible exception capture. Note: unlike Sentry, no event ID is returned (a Faro limitation).

import { captureException } from '@nais/apm';

try {
  risky();
} catch (e) {
  captureException(e, {
    context: { orderId: '123' },
    fingerprint: 'checkout-timeout', // custom grouping key
  });
}

captureMessage(message, level?)

import { captureMessage } from '@nais/apm';

captureMessage('fallback flow used', 'warning'); // 'fatal' | 'error' | 'warning' | 'log' | 'info' | 'debug'

setUser(user) / clearUser()

NAV operates on identities, not emails — and identities are PII that must not reach the shared Loki instance (every team shares it). Pass only an opaque, non-identifying correlation key: a salted hash of the identity, never a raw NAV ident, fødselsnummer, email, or name.

import { setUser, clearUser } from '@nais/apm';

setUser({ id: hashedSubject }); // hashedSubject = a salted hash, NOT an ident/fnr/email
// ... on logout:
clearUser();

As a safety net, setUser defensively drops any id/username/attributes value that looks like PII (fødselsnummer, email, or a raw NAV ident) and warns once; the email field is deprecated and dropped unconditionally.

setTag(key, value)

Approximation of Sentry.setTag — Faro has no first-class tag/label concept, so the value rides along as context on every subsequent capture rather than as an indexed label.

import { setTag } from '@nais/apm';

setTag('featureFlag.newCheckout', true);

setContext(name, context)

Attaches named context, flattened as name.key, to every subsequent capture. Pass null to remove a previously set context.

import { setContext } from '@nais/apm';

setContext('order', { id: '123', total: 499 });
setContext('order', null); // remove it

captureFeedback(message, options?)

Free-text user feedback capture — no direct Sentry equivalent, this is @nais/apm's own addition. Feedback is joined to the current session automatically, and optionally to a specific issue via fingerprint.

Preview — internal-pilot only, not GA. The free-text message lands in the shared Loki instance, so any UI you wire to this must show a clear "do not enter personal information" warning next to the input, and — like session replay — it is gated on the personvernombud process for citizen-facing use.

import { captureFeedback } from '@nais/apm';

captureFeedback('The export button did nothing', {
  category: 'bug', // 'bug' | 'idea' | 'other', default 'other'
  email: 'user@example.com', // optional, only sent if it looks like a real email
  fingerprint: 'export-button-noop',
  context: { page: 'reports' },
});

isInitialized()

import { isInitialized } from '@nais/apm';

if (!isInitialized()) {
  init();
}

scrubString(value)

Exposes the PII scrubber directly, e.g. if you want to sanitize a string before logging it yourself.

import { scrubString } from '@nais/apm';

console.log(scrubString('contact me at user@example.com'));
// -> "contact me at [email]"

Privacy: PII scrubbing (mandatory)

Every outgoing signal (exception values, stack traces, log lines, context values, and the page URL) passes through a beforeSend scrubbing pipeline before it leaves the browser:

  • Norwegian fødselsnummer (11 digits, sanity-checked against a plausible date prefix, including D-numbers, H-numbers, and synthetic test numbers) → [fnr]
  • Email addresses[email]
  • Token-bearing URL parameters (token, access_token, id_token, refresh_token, code, state) → [redacted]

Your own init({ beforeSend }) hook (if any) runs first and may drop items by returning null; the scrubber always runs last, so it also sees anything your hook added.

Opt-out requires an explicit init({ dangerouslyDisablePiiScrubbing: true }). If you do that, your team owns the GDPR consequences of everything the app sends to Loki. Scrubbing is regex-based and best-effort — it is a safety net, not a GDPR guarantee. Do not put personal data in error messages in the first place.

Session replay & crash snapshots (preview — opt-in, not GA)

Preview, not GA. sessionReplay and screenshotOnError (like captureFeedback) push DOM/snapshot/free-text data that lands in the shared Loki instance — they can carry user content into a shared log store. They are internal-apps-first and gated on NAV's personvernombud (data protection officer) process. Do not enable them on citizen-facing apps without sign-off. The masking floor below is a safety net, not a substitute for that assessment.

Two related, disabled-by-default features let a team see what a user's screen looked like around an error:

  • sessionReplay — records the session (via rrweb) and, in the default on-error mode, only ships the last ~60–120 seconds once an error actually occurs (nothing leaves the browser before that). mode: 'always' streams continuously instead, gated by sampleRate.
  • screenshotOnError — captures one masked DOM snapshot per new error (throttled, capped per session), without recording a full session. Automatically disabled when sessionReplay is on, since a recording's checkout already contains the same information.
init({
  sessionReplay: {
    enabled: true,
    mode: 'on-error', // or 'always'
    sampleRate: 0.5, // fraction of sessions recorded, 0..1
    block: ['.no-record-me'], // extra CSS selectors to block; tighten-only
  },
});

These features are opt-in for a reason: a team must decide, deliberately, to turn on screen recording for its own users. This is not a technical toggle to flip lightly — it reflects NAV's personvernombud (data protection officer) process, and each team is responsible for making that call for its own application and users, in line with its own privacy assessment.

To make that decision safer regardless of which way it goes, both features share a non-overridable masking floor, applied in the browser before any byte leaves the user's machine:

  • every form input value is masked, with no exceptions — inputs can never be unmasked, not even via the allowlist below;
  • all text is masked, except elements explicitly marked with a data-apm-unmask attribute;
  • images, video, audio, canvas, iframes, embeds, and anything marked data-apm-block are always blocked, never inlined;
  • stylesheets and images are never inlined into the capture.

The block option can only add more selectors to block — there is no option to relax any part of the masking floor.

Local development

On localhost (or anywhere no collector URL resolves), init():

  • warns once: [@nais/apm] No telemetry collector URL resolved …,
  • sends nothing over the network,
  • echoes every signal to the browser console instead, so you can see exactly what would have been sent.

Calling captureException/captureMessage/setUser/etc. before init() is a safe no-op (with a single warning).

Escape hatch

init({ faro: { ... } }) accepts raw Faro BrowserConfig overrides for anything this package doesn't expose directly. beforeSend is the one exception — it stays composed with the mandatory PII scrubber rather than being fully overridden.

Browser tracing

Opt in with init({ tracing: true }). This lazily loads @grafana/faro-web-tracing (kept out of your bundle unless you enable it) and starts propagating W3C trace-context headers so browser spans join their backend traces in Tempo.

init({ tracing: true });

Trace headers are only ever sent to nais-owned backends: a non-overridable floor restricts propagation to the app's own origin and any https://*.nav.no host. You can add more origins, but you can never remove the floor (and it is not reachable through the faro escape hatch):

init({ tracing: { propagateExtraOrigins: ['https://api.partner.example'] } });

React — @nais/apm/react

A separate entry point with React helpers. React and react-router are optional peer dependencies; importing this entry requires them (plus @grafana/faro-react for the React Router v6 wiring). The root @nais/apm entry stays free of React and OpenTelemetry.

Error boundary — catches render errors and reports them once through captureException (so they get the SDK's fingerprint/context pipeline):

import { ApmErrorBoundary } from '@nais/apm/react';

<ApmErrorBoundary fingerprint="checkout" fallback={<p>Something went wrong.</p>}>
  <Checkout />
</ApmErrorBoundary>;

There is also a withApmErrorBoundary(Component, props?) HOC.

Route tracking (React Router v6) — call once after init(), passing your own react-router-dom exports, then render <ApmRoutes> in place of <Routes>:

import { createRoutesFromChildren, matchRoutes, Routes, useLocation, useNavigationType } from 'react-router-dom';
import { enableApmReactRouterV6, ApmRoutes } from '@nais/apm/react';

enableApmReactRouterV6({ createRoutesFromChildren, matchRoutes, Routes, useLocation, useNavigationType });

Route tracking (Next.js App Router) — use the useApmRouteTracking hook in a client component:

'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useApmRouteTracking } from '@nais/apm/react';

export function ApmRouteTracker() {
  useApmRouteTracking(usePathname(), useSearchParams());
  return null;
}

Next.js client initinitNaisAPMClient is the entry for Next 15+ instrumentation-client.ts (and the Pages Router _app.tsx). It no-ops on the server and is idempotent under StrictMode:

// instrumentation-client.ts
import { initNaisAPMClient } from '@nais/apm/react';
initNaisAPMClient({ namespace: 'my-team', tracing: true });

React Router v5/v7 and the data-router variants are follow-ups.

Versioning & stability

This package follows semver, but is pre-1.0: expect the public API to keep moving (new options, renamed fields, new exports like tracing and React helpers) across 0.x minor releases. Pin an exact version in your package.json and read the CHANGELOG before upgrading. Breaking changes will be called out there.

Not yet supported

Planned for later 0.x releases:

  • React Router v5/v7 & data routers — route tracking currently covers React Router v6 and the Next.js App Router.
  • Registry — published to GitHub Package Registry only; a move to npmjs.org (dropping the read:packages token requirement for installs) is under consideration.

Development

pnpm install
pnpm test        # vitest (jsdom)
pnpm build       # tsc -> dist/

License

MIT

About

Nais APM Client

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors