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

  • 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

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

Validation

# 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

Edge Cases & Troubleshooting

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.