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