Skip to main content

Plugin Analytics

Setting up, maintaining, and iterating on an instance of Backstage can be a large investment. To help measure return on this investment, Backstage comes with an event-based Analytics API that grants app integrators the flexibility to collect and analyze Backstage usage in the analytics tool of their choice, while providing plugin developers a standard interface for instrumenting key user interactions.

Concepts

  • Events consist of, at a minimum, an action (like click) and a subject (like thing that was clicked on).
  • Attributes represent additional dimensional data (in the form of key/value pairs) that may be provided on an event-by-event basis. To continue the above example, the URL a user clicked to might look like { "to": "/a/page" }.
  • Context represents the broader context in which an event took place. By default, information like pluginId, extension, and routeRef are provided.

This composition of events aims to allow analysis at different levels of detail, enabling very granular questions (like "what is the most clicked on thing on a particular route") as well as very high-level questions (like "what is the most used plugin in my Backstage instance") to be answered.

Supported Analytics Tools

While all that's needed to consume and forward these events to an analytics tool is a concrete implementation of AnalyticsApi, common integrations are packaged and provided as plugins. Find your analytics tool of choice below.

Analytics ToolSupport Status
Google AnalyticsYes ✅
Google Analytics 4Yes ✅
New Relic BrowserCommunity ✅
MatomoCommunity ✅
Quantum MetricCommunity ✅

To suggest an integration, please open an issue for the analytics tool your organization uses. Or jump to Writing Integrations to learn how to contribute the integration yourself!

Key Events

The following table summarizes events that, depending on the plugins you have installed, may be captured.

ActionSubjectOther Notes
navigateThe URL of the page that was navigated to.Fired immediately when route location changes (unless associated plugin/route data is ambiguous, in which case the event is fired after plugin/route data becomes known, immediately before the next event or document unload). The parameters of the current route will be included as attributes.
clickThe text of the link that was clicked on.The to attribute represents the URL clicked to.
createThe name of the software being created; if no name property is requested by the given Software Template, then the string new {templateName} is used instead.The context holds an entityRef, set to the template's ref (e.g. template:default/template-name).
searchThe search term entered in any search bar component.The context holds searchTypes, representing types constraining the search. The value represents the total number of search results for the query. This may not be visible if the permission framework is being used.
discoverThe title of the search result that was clicked onThe value is the result rank. A to attribute is also provided.
not-foundThe path of the resource that resulted in a not found pageFired by at least TechDocs.

If there is an event you'd like to see captured, please open an issue describing the event you want to see and the questions it would help you answer. Or jump to Capturing Events to learn how to contribute the instrumentation yourself!

OSS plugin maintainers: feel free to document your events in the table above.

Writing Integrations

Analytics event forwarding is implemented as a Backstage utility API. Just as you might provide a custom API implementation for errors or SCM Authentication, you can provide one for analytics.

The provided API need only provide a single method captureEvent, which takes an AnalyticsEvent object.

import {
analyticsApiRef,
AnalyticsEvent,
AnyApiFactory,
createApiFactory,
} from '@backstage/core-plugin-api';

export const apis: AnyApiFactory[] = [
createApiFactory(analyticsApiRef, {
captureEvent: (event: AnalyticsEvent) => {
window._AcmeAnalyticsQ.push(event);
},
}),
];

In reality, you would likely want to encapsulate instantiation logic and pull some details from configuration. A more complete example might look like:

import {
AnalyticsApi,
analyticsApiRef,
AnalyticsEvent,
AnyApiFactory,
configApiRef,
createApiFactory,
} from '@backstage/core-plugin-api';
import { AcmeAnalytics } from 'acme-analytics';

class AcmeAnalytics implements AnalyticsApi {
private constructor(accountId: number) {
AcmeAnalytics.init(accountId);
}

static fromConfig(config) {
const accountId = config.getString('app.analytics.acme.id');
return new AcmeAnalytics(accountId);
}

captureEvent(event: AnalyticsEvent) {
const { action, ...rest } = event;
AcmeAnalytics.send(action, rest);
}
}

export const apis: AnyApiFactory[] = [
createApiFactory({
api: analyticsApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) => AcmeAnalytics.fromConfig(configApi),
}),
];

If you are integrating with an analytics service (as opposed to an internal tool), consider contributing your API implementation as a plugin!

By convention, such packages should be named @backstage/analytics-module-[name], and any configuration should be keyed under app.analytics.[name].

Handling User Identity

If the analytics platform you are integrating with has a first-class concept of user identity, you can (optionally) choose to support this by the following this convention:

  • Allow your implementation to be instantiated with the identityApi as one of its options in a fromConfig static method.
  • Use the userEntityRef resolved by identityApi's getBackstageIdentity() method as the basis for the user ID you send to your analytics platform.

For example:

import {
AnalyticsApi,
analyticsApiRef,
AnyApiFactory,
configApiRef,
createApiFactory,
identityApiRef,
IdentityApi,
} from '@backstage/core-plugin-api';

// Implementation that optionally initializes with a userId.
class AcmeAnalytics implements AnalyticsApi {
private constructor(accountId: number, identityApi?: IdentityApi) {
if (identityApi) {
identityApi.getBackstageIdentity().then(identity => {
AcmeAnalytics.init(accountId, {
userId: identity.userEntityRef,
});
});
} else {
AcmeAnalytics.init(accountId);
}
}

static fromConfig(config, options) {
const accountId = config.getString('app.analytics.acme.id');
return new AcmeAnalytics(accountId, options.identityApi);
}
}

// Your implementation should be instantiated like this:
export const apis: AnyApiFactory[] = [
createApiFactory({
api: analyticsApiRef,
deps: { configApi: configApiRef, identityApi: identityApiRef },
factory: ({ configApi, identityApi }) =>
AcmeAnalytics.fromConfig(configApi, {
identityApi,
}),
}),
];

Capturing Events

To instrument an event in a component, start by retrieving an analytics tracker using the useAnalytics() hook provided by @backstage/core-plugin-api. The tracker includes a captureEvent method which takes an action and a subject as arguments.

import { useAnalytics } from '@backstage/core-plugin-api';

const analytics = useAnalytics();
analytics.captureEvent('deploy', serviceName);

Providing Extra Attributes

Additional dimensional attributes as well as a numeric value can be provided on a third options argument if/when relevant for the event:

analytics.captureEvent('merge', pullRequestName, {
value: pullRequestAgeInMinutes,
attributes: {
org,
repo,
},
});

In the above example, an event resembling the following object would be captured:

{
"action": "merge",
"subject": "Name of Pull Request",
"value": 60,
"attributes": {
"org": "some-org",
"repo": "some-repo"
}
}

Providing Context for Events

The attributes option is good for capturing details available to you within the component that you're instrumenting. For capturing metadata only available further up the react tree, or to help app integrators aggregate distinct events by some common value, use an <AnalyticsContext>.

import { AnalyticsContext, useAnalytics } from '@backstage/core-plugin-api';

const MyComponent = ({ value }) => {
const analytics = useAnalytics();
const handleClick = () => analytics.captureEvent('check', value);
return <SomeThing value={value} onClick={handleClick} />;
};

const MyWrapper = () => {
return (
<AnalyticsContext attributes={{ segment: 'xyz' }}>
<MyComponent value={'Some Value'} />
</AnalyticsContext>
);
};

In the above example, clicking on <SomeThing /> would result in an analytics event resembling:

{
"action": "check",
"subject": "Some Value",
"context": {
"segment": "xyz"
}
}

Note that, for brevity in the example above, the context keys provided by Backstage core (pluginId, extension, and routeRef) have been omitted. In reality, those details would be included alongside any additional context provided by you.

Analytics contexts can be nested; their values are merged down the react tree, allowing keys to be overwritten.

Event Naming Considerations

An event is split into its constituent parts to enable analysis at various levels of granularity. In order to maintain this flexibility at analysis-time, it's important to keep each of these levels of detail disaggregated.

  • Avoid providing an overly specific action. For example, instead of filterEntityTable, consider just using filter as the action, and allowing EntityTable to be specified as part of the event's context (most likely automatically as part of the extension in which the filter event was captured).

  • On the flip side, when adding attributes to or context around an event, look at existing events and see if the data you are capturing matches the intention, type, or even the content of their attributes or context. For instance, it's common for events that involve the Catalog to include an entityRef contextual key. Using the same keys and values in your event will ensure that events instrumented across plugins can easily be aggregated.

Unit Testing Event Capture

The @backstage/test-utils package includes a MockAnalyticsApi implementation that you can use in your unit tests to spy on and make assertions about any analytics events captured.

Use it like this:

import { render, fireEvent, waitFor } from '@testing-library/react';
import { analyticsApiRef } from '@backstage/core-plugin-api';
import {
MockAnalyticsApi,
TestApiProvider,
wrapInTestApp,
} from '@backstage/test-utils';

describe('SomeComponent', () => {
it('should capture event on click', () => {
// Use the Mock Analytics API to spy on event captures.
const apiSpy = new MockAnalyticsApi();

// Render the component being tested
const { getByText } = render(
wrapInTestApp(
<TestApiProvider apis={[[analyticsApiRef, apiSpy]]}>
<SomeComponentUnderTest />
</TestApiProvider>,
),
);

// Fire the event that triggers event capture.
fireEvent.click(getByText('some component text'));

// Assert that the event was captured with the expected data.
await waitFor(() => {
expect(apiSpy.getEvents()[0]).toMatchObject({
action: 'expected action',
subject: 'expected subject',
attributes: {
foo: 'bar',
},
});
});
});
});