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.
- Backstage
>= 1.20.0using the new backend system. @backstage/core-plugin-api>= 1.9.0, which exposesanalyticsApiRef,useAnalytics, and theAnalyticsApiinterface.- 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.tsand 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.
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.
3. Configure debug and consent toggles
# 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.
# 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.
| 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.