Instrumenting Portal Usage Analytics

Portal usage analytics answers a question DORA cannot: are engineers actually using the self-service features you shipped? This how-to wires Backstage’s analytics API to a real provider, emits both automatic navigation events and explicit DevEx events, and scrubs identifiers before they leave the browser. It is the adoption half of the Developer Experience Metrics capability in Developer Experience & Self-Service Platforms, and complements Measuring Developer Experience with DORA Metrics.

Prerequisites

Instrumentation needs four things: the analytics API surface, a collection endpoint to receive events, write access to the app entrypoint, and an agreed retention/PII policy so you know what may leave the browser.

Prerequisites for portal usage analytics The analytics API, a collection endpoint, app access, and a PII policy make usage instrumentation possible. analytics API collection endpoint app access PII policy Instrumentable governed events
Agree the PII policy before writing the scrubber, so it strips exactly what your retention rules forbid.
  • Backstage >= 1.20.0 using the new backend system.
  • @backstage/core-plugin-api >= 1.9.0, which exposes analyticsApiRef, useAnalytics, and the AnalyticsApi interface.
  • A collection endpoint: a self-hosted provider (PostHog, Matomo) or an internal events service. This guide uses a generic HTTP collector with a batching adapter.
  • Write access to packages/app/src/apis.ts and the app config.
  • Agreement on a retention and PII policy aligned with your audit logging and compliance standards.

Exact Configuration

The event lifecycle runs entirely client-side until the flush: an event is captured, scrubbed of identifiers in the browser, queued, and sent to the collector in batches. Scrubbing before the queue is what keeps PII from ever leaving the page.

Client-side analytics event lifecycle An event is captured, scrubbed in the browser, queued, and flushed in a batch to the collector. captureEvent nav or custom Scrub strip PII Queue in memory Batch flush keepalive POST
Batching with keepalive is what stops events from being lost when the user closes the tab mid-session.

1. Implement the AnalyticsApi

// packages/app/src/analytics/GenericAnalytics.ts
// Requires @backstage/core-plugin-api >= 1.9.0
import {
  AnalyticsApi,
  AnalyticsEvent,
  ConfigApi,
} from '@backstage/core-plugin-api';

export class GenericAnalytics implements AnalyticsApi {
  private queue: AnalyticsEvent[] = [];

  private constructor(
    private readonly endpoint: string,
    private readonly debug: boolean,
  ) {
    // Flush on an interval to batch network calls
    setInterval(() => this.flush(), 10_000);
  }

  static fromConfig(config: ConfigApi, opts: { endpoint: string }) {
    return new GenericAnalytics(
      opts.endpoint,
      config.getOptionalBoolean('analytics.debug') ?? false,
    );
  }

  captureEvent(event: AnalyticsEvent) {
    const scrubbed = this.scrub(event);
    if (this.debug) {
      // eslint-disable-next-line no-console
      console.debug('[analytics]', scrubbed);
      return;
    }
    this.queue.push(scrubbed);
  }

  private scrub(event: AnalyticsEvent): AnalyticsEvent {
    // Strip query strings and emails before anything leaves the browser
    const subject = event.subject?.replace(/[?#].*$/, '') ?? '';
    return { ...event, subject };
  }

  private async flush() {
    if (this.queue.length === 0) return;
    const batch = this.queue.splice(0, this.queue.length);
    await fetch(`${this.endpoint}/collect`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ events: batch }),
      keepalive: true,
    }).catch(() => this.queue.unshift(...batch));
  }
}

2. Register the API factory

// packages/app/src/apis.ts
// Requires @backstage/core-plugin-api >= 1.9.0
import {
  analyticsApiRef,
  configApiRef,
  createApiFactory,
} from '@backstage/core-plugin-api';
import { GenericAnalytics } from './analytics/GenericAnalytics';

export const apis = [
  createApiFactory({
    api: analyticsApiRef,
    deps: { configApi: configApiRef },
    factory: ({ configApi }) =>
      GenericAnalytics.fromConfig(configApi, {
        endpoint: process.env.ANALYTICS_ENDPOINT ?? '',
      }),
  }),
];

Registering analyticsApiRef automatically captures route navigations across the whole portal — no per-page wiring needed.

# app-config.yaml
# Requires Backstage >= 1.20.0
analytics:
  # When true, events log to console and are NOT sent — use in dev
  debug: ${ANALYTICS_DEBUG}

4. Emit explicit DevEx events

// packages/app/src/components/catalog/SearchTracker.tsx
// Requires @backstage/core-plugin-api >= 1.9.0
import { useAnalytics } from '@backstage/core-plugin-api';

export function trackSearch(term: string, resultCount: number) {
  const analytics = useAnalytics();
  // Distinguish productive searches (results) from dead-ends (zero)
  analytics.captureEvent('catalog_search', term, {
    attributes: { resultCount, productive: resultCount > 0 },
  });
}

Validate three behaviors: with debug on, events log to console and never leave the browser; with debug off, the collector accepts batches; and stored subjects contain no query strings, proving the scrubber ran.

Three analytics validation checks Debug logs to console, the collector accepts batches, and stored subjects have no query strings. Debug → console nothing sent Collector 202 batch accepted No query strings scrubber ran
The scrubber check is the privacy gate — if query strings survive, so might tokens embedded in URLs.
# Requires Backstage >= 1.20.0
# 1. With debug on, events should appear in the browser console, not the network.
#    Set ANALYTICS_DEBUG=true and watch DevTools console for "[analytics]" lines.

# 2. With debug off, the collector should accept batches (expect 202)
curl -s -o /dev/null -w "%{http_code}\n" \
  -X POST "${ANALYTICS_ENDPOINT}/collect" \
  -H "Content-Type: application/json" \
  -d '{"events":[{"action":"navigate","subject":"/catalog"}]}'
# Expected: 202

# 3. Confirm scrubbing: query strings must not appear in stored subjects
curl -s "${ANALYTICS_ENDPOINT}/query?action=navigate" \
  | jq '[.events[].subject | test("[?]")] | any'
# Expected: false

The analytics failures split into delivery problems (no events, lost on close, 413s) and data-quality problems (leaked PII, duplicate events). The diagram groups the symptoms so the fix targets the transport or the capture logic.

Analytics failures by class No events, lost-on-close, and oversized batches are delivery problems; leaked PII and duplicates are data-quality problems. Delivery no events lost / 413 → keepalive + chunk Data quality leaked PII duplicate views → scrub + stable hooks
Emitting custom events only on stable callbacks — not on every re-render — eliminates the duplicate-view problem.
Symptom Root Cause Resolution
No events recorded analyticsApiRef not registered or debug left on Confirm the factory is in apis.ts and analytics.debug is false in the target env
Events lost on tab close Batch never flushed Use fetch(..., { keepalive: true }) and flush on visibilitychange
Emails appearing in subjects Scrub regex too narrow Extend scrub to strip known PII patterns; prefer allowlisting attributes
Duplicate page views Component re-renders calling captureEvent Move automatic capture to the registered API; emit custom events on stable callbacks only
Collector returns 413 Batch too large Cap batch size in flush and send in chunks

Frequently Asked Questions

Do we need user consent banners for internal portal analytics?

Internal tooling usually falls under employment data-processing notices rather than public consent banners, but the events still contain user identifiers and must follow your organization’s retention and access rules. Govern them under the same policy as audit logs and scrub anything not needed for product decisions.

What is the difference between automatic and custom events?

Registering the analytics API automatically captures navigation between routes, which is enough to build funnels. Custom events via useAnalytics().captureEvent add semantic actions — a completed template run, a productive vs. empty search — that navigation alone cannot infer.