Initial implementation of an extension-based default analytics API

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2025-03-25 16:46:10 +01:00
parent b5a1fe0144
commit 93b5e38f8b
12 changed files with 498 additions and 15 deletions
+18
View File
@@ -0,0 +1,18 @@
---
'@backstage/frontend-plugin-api': patch
---
Plugins should now use the new `AnalyticsBlueprint` to define and provide concrete analytics implementations. For example:
```ts
import { AnalyticsBlueprint } from '@backstage/frontend-plugin-api';
const AcmeAnalytics = AnalyticsBlueprint.make({
name: 'acme-analytics',
params: define =>
define({
deps: { config: configApiRef },
factory: ({ config }) => AcmeAnalyticsImpl.fromConfig(config),
}),
});
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app': patch
---
The default implementation of the Analytics API now collects and instantiates analytics implementations exposed via `AnalyticsBlueprint` extensions. If no such extensions are discovered, the API continues to do nothing with analytics events fired within Backstage. If multiple such extensions are discovered, every discovered implementation automatically receives analytics events.
+30
View File
@@ -101,6 +101,24 @@ export const apis: AnyApiFactory[] = [
},
}),
];
// Or, when building for the new frontend system:
import { AnalyticsBlueprint } from '@backstage/frontend-plugin-api';
export const acmeAnalyticsImplementation = AnalyticsBlueprint.make({
name: 'acme',
params: define =>
define({
deps: {},
factory() {
return {
captureEvent: event => {
window._AcmeAnalyticsQ.push(event);
},
};
},
}),
});
```
In reality, you would likely want to encapsulate instantiation logic and pull
@@ -140,6 +158,18 @@ export const apis: AnyApiFactory[] = [
factory: ({ configApi }) => AcmeAnalytics.fromConfig(configApi),
}),
];
// Or, when building for the new frontend system:
import { AnalyticsBlueprint } from '@backstage/frontend-plugin-api';
export const acmeAnalyticsImplementation = AnalyticsBlueprint.make({
name: 'acme',
params: define =>
define({
deps: { configApi: configApiRef },
factory: ({ configApi }) => AcmeAnalytics.fromConfig(configApi),
}),
});
```
If you are integrating with an analytics service (as opposed to an internal
+40 -1
View File
@@ -99,7 +99,7 @@ export { alertApiRef };
export { AlertMessage };
// @public
// @public @deprecated
export type AnalyticsApi = {
captureEvent(event: AnalyticsEvent): void;
};
@@ -107,6 +107,30 @@ export type AnalyticsApi = {
// @public
export const analyticsApiRef: ApiRef<AnalyticsApi>;
// @public
export const AnalyticsBlueprint: ExtensionBlueprint<{
kind: 'analytics';
name: undefined;
params: <TDeps extends { [name in string]: unknown }>(
params: AnalyticsImplementationFactory<TDeps>,
) => ExtensionBlueprintParams<AnalyticsImplementationFactory<{}>>;
output: ConfigurableExtensionDataRef<
AnalyticsImplementationFactory<{}>,
'core.analytics.factory',
{}
>;
inputs: {};
config: {};
configInput: {};
dataRefs: {
factory: ConfigurableExtensionDataRef<
AnalyticsImplementationFactory<{}>,
'core.analytics.factory',
{}
>;
};
}>;
// @public
export const AnalyticsContext: (options: {
attributes: Partial<AnalyticsContextValue>;
@@ -135,6 +159,21 @@ export type AnalyticsEventAttributes = {
[attribute in string]: string | boolean | number;
};
// @public
export type AnalyticsImplementation = {
captureEvent(event: AnalyticsEvent): void;
};
// @public (undocumented)
export type AnalyticsImplementationFactory<
Deps extends {
[name in string]: unknown;
} = {},
> = {
deps: TypesToApiRefs<Deps>;
factory(deps: Deps): AnalyticsImplementation;
};
// @public
export type AnalyticsTracker = {
captureEvent: (
@@ -16,6 +16,7 @@
import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
import { AnalyticsContextValue } from '../../analytics/types';
import type { AnalyticsBlueprint } from '../../blueprints/';
/**
* Represents an event worth tracking in an analytics system that could inform
@@ -102,6 +103,26 @@ export type AnalyticsTracker = {
) => void;
};
/**
* Analytics implementations are used to track user behavior in a Backstage
* instance.
*
* @remarks
*
* To instrument your App or Plugin, retrieve an analytics tracker using the
* `useAnalytics()` hook. This will return a pre-configured `AnalyticsTracker`
* with relevant methods for instrumentation.
*
* @public
*/
export type AnalyticsImplementation = {
/**
* Primary event handler responsible for compiling and forwarding events to
* an analytics system.
*/
captureEvent(event: AnalyticsEvent): void;
};
/**
* The Analytics API is used to track user behavior in a Backstage instance.
*
@@ -112,6 +133,8 @@ export type AnalyticsTracker = {
* with relevant methods for instrumentation.
*
* @public
* @deprecated This type is now deprecated and will be removed in an upcoming
* release. Use the {@link AnalyticsImplementation} type instead.
*/
export type AnalyticsApi = {
/**
@@ -124,6 +147,11 @@ export type AnalyticsApi = {
/**
* The API reference of {@link AnalyticsApi}.
*
* @remarks
*
* To define a concrete Analytics Implementation, use {@link AnalyticsBlueprint}
* instead.
*
* @public
*/
export const analyticsApiRef: ApiRef<AnalyticsApi> = createApiRef({
@@ -0,0 +1,56 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AnalyticsBlueprint } from './AnalyticsBlueprint';
describe('AnalyticsBlueprint', () => {
it('should create an extension with sensible defaults', () => {
const factory = {
deps: {},
factory: () => ({ captureEvent: () => {} }),
};
const extension = AnalyticsBlueprint.make({
params: define => define(factory),
name: 'test',
});
expect(extension).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"T": undefined,
"attachTo": [
{
"id": "api:app/analytics",
"input": "analyticsImplementations",
},
],
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "analytics",
"name": "test",
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
});
@@ -0,0 +1,55 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AnalyticsImplementation, TypesToApiRefs } from '../apis';
import {
createExtensionBlueprint,
createExtensionBlueprintParams,
createExtensionDataRef,
} from '../wiring';
/** @public */
export type AnalyticsImplementationFactory<
Deps extends { [name in string]: unknown } = {},
> = {
deps: TypesToApiRefs<Deps>;
factory(deps: Deps): AnalyticsImplementation;
};
const factoryDataRef =
createExtensionDataRef<AnalyticsImplementationFactory>().with({
id: 'core.analytics.factory',
});
/**
* Creates analytics implementations.
*
* @public
*/
export const AnalyticsBlueprint = createExtensionBlueprint({
kind: 'analytics',
attachTo: [{ id: 'api:app/analytics', input: 'analyticsImplementations' }],
output: [factoryDataRef],
dataRefs: {
factory: factoryDataRef,
},
defineParams: <TDeps extends { [name in string]: unknown }>(
params: AnalyticsImplementationFactory<TDeps>,
) => createExtensionBlueprintParams(params as AnalyticsImplementationFactory),
*factory(params) {
yield factoryDataRef(params);
},
});
@@ -14,6 +14,10 @@
* limitations under the License.
*/
export {
AnalyticsBlueprint,
type AnalyticsImplementationFactory,
} from './AnalyticsBlueprint';
export { ApiBlueprint } from './ApiBlueprint';
export { AppRootElementBlueprint } from './AppRootElementBlueprint';
export { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint';
+16 -3
View File
@@ -3,6 +3,7 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnalyticsImplementationFactory } from '@backstage/frontend-plugin-api';
import { AnyApiFactory } from '@backstage/frontend-plugin-api';
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ApiFactory } from '@backstage/frontend-plugin-api';
@@ -229,8 +230,6 @@ const appPlugin: FrontendPlugin<
) => ExtensionBlueprintParams<AnyApiFactory>;
}>;
'api:app/analytics': ExtensionDefinition<{
kind: 'api';
name: 'analytics';
config: {};
configInput: {};
output: ConfigurableExtensionDataRef<
@@ -238,7 +237,21 @@ const appPlugin: FrontendPlugin<
'core.api.factory',
{}
>;
inputs: {};
inputs: {
analyticsImplementations: ExtensionInput<
ConfigurableExtensionDataRef<
AnalyticsImplementationFactory<{}>,
'core.analytics.factory',
{}
>,
{
singleton: false;
optional: false;
}
>;
};
kind: 'api';
name: 'analytics';
params: <
TApi,
TImpl extends TApi,
+2 -11
View File
@@ -17,7 +17,6 @@
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
AlertApiForwarder,
NoOpAnalyticsApi,
ErrorApiForwarder,
ErrorAlerter,
GoogleAuth,
@@ -40,7 +39,6 @@ import {
import {
alertApiRef,
analyticsApiRef,
errorApiRef,
discoveryApiRef,
fetchApiRef,
@@ -70,6 +68,7 @@ import {
IdentityPermissionApi,
} from '@backstage/plugin-permission-react';
import { DefaultDialogApi } from './apis/DefaultDialogApi';
import { AnalyticsApi } from './extensions/AnalyticsApi';
export const apis = [
ApiBlueprint.make({
@@ -102,15 +101,7 @@ export const apis = [
factory: () => new AlertApiForwarder(),
}),
}),
ApiBlueprint.make({
name: 'analytics',
params: defineParams =>
defineParams({
api: analyticsApiRef,
deps: {},
factory: () => new NoOpAnalyticsApi(),
}),
}),
AnalyticsApi,
ApiBlueprint.make({
name: 'error',
params: defineParams =>
@@ -0,0 +1,162 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createExtensionTester } from '@backstage/frontend-test-utils';
import { AnalyticsApi } from './AnalyticsApi';
import {
AnalyticsBlueprint,
AnalyticsImplementation,
ApiBlueprint,
configApiRef,
createExtension,
identityApiRef,
} from '@backstage/frontend-plugin-api';
describe('AnalyticsApi', () => {
const mockEvent = {
action: '',
subject: '',
context: {
pluginId: '',
extensionId: '',
routeRef: '',
},
};
const captureEventSpy1 = jest.fn();
const MockImplementation1 = createExtension({
name: 'mock-implementation-1',
attachTo: { id: 'api:analytics', input: 'analyticsImplementations' },
output: [AnalyticsBlueprint.dataRefs.factory],
factory() {
return [
AnalyticsBlueprint.dataRefs.factory({
deps: { configApi: configApiRef },
factory: deps => ({
captureEvent: event => captureEventSpy1(event, deps),
}),
}),
];
},
});
const captureEventSpy2 = jest.fn();
const MockImplementation2 = createExtension({
name: 'mock-implementation-2',
attachTo: { id: 'api:analytics', input: 'analyticsImplementations' },
output: [AnalyticsBlueprint.dataRefs.factory],
factory() {
return [
AnalyticsBlueprint.dataRefs.factory({
deps: { config: configApiRef, identityApi: identityApiRef },
factory: deps => ({
captureEvent: event => captureEventSpy2(event, deps),
}),
}),
];
},
});
beforeEach(() => {
jest.clearAllMocks();
});
it('wires up a single AnalyticsImplementationFactory', async () => {
// Given the Analytics API and a single mock implementation
const tester = createExtensionTester(AnalyticsApi).add(MockImplementation1);
const apiFactory = tester.get(ApiBlueprint.dataRefs.factory);
// Then the API's deps should contain the mock implementation's deps.
expect(apiFactory.deps).toMatchObject({
'core.config': expect.anything(),
});
// And the returned instance should be an Analytics Implementation
const concreteImplementation = apiFactory.factory({
'core.config': 'ConfigApiImplementation',
});
expect(concreteImplementation).toHaveProperty('captureEvent');
// When the resulting API's captureEvent method is called
(concreteImplementation as AnalyticsImplementation).captureEvent(mockEvent);
// Then the mock implementation's captureEvent should have been called
expect(captureEventSpy1.mock.calls[0][0]).toEqual(mockEvent);
// And the mock's factory should have resolved the expected deps
expect(captureEventSpy1.mock.calls[0][1]).toEqual({
configApi: 'ConfigApiImplementation',
});
});
it('wires up more than one AnalyticsImplementationFactory', () => {
// Given the Analytics API and two mock implementations
const tester = createExtensionTester(AnalyticsApi)
.add(MockImplementation1)
.add(MockImplementation2);
const apiFactory = tester.get(ApiBlueprint.dataRefs.factory);
// Then the API's deps should contain the mock implementations' deps.
expect(apiFactory.deps).toMatchObject({
'core.config': expect.anything(),
'core.identity': expect.anything(),
});
// And the returned instance should be an Analytics Implementation
const concreteImplementation = apiFactory.factory({
'core.config': 'ConfigApiImplementation',
'core.identity': 'IdentityApiImplementation',
});
expect(concreteImplementation).toHaveProperty('captureEvent');
// When the resulting API's captureEvent method is called
(concreteImplementation as AnalyticsImplementation).captureEvent(mockEvent);
// Then the 1st mock implementation's captureEvent should have been called
expect(captureEventSpy1.mock.calls[0][0]).toEqual(mockEvent);
// And the 1st mock's factory should have resolved the expected deps
expect(captureEventSpy1.mock.calls[0][1]).toEqual({
configApi: 'ConfigApiImplementation',
});
// And the 2nd mock implementation's captureEvent should have been called
expect(captureEventSpy2.mock.calls[0][0]).toEqual(mockEvent);
// And the 2nd mock's factory should have resolved the expected deps
expect(captureEventSpy2.mock.calls[0][1]).toEqual({
config: 'ConfigApiImplementation',
identityApi: 'IdentityApiImplementation',
});
});
it('works fine with no AnalyticsImplementationFactory instances provided', () => {
// Given the Analytics API and no mock implementations
const tester = createExtensionTester(AnalyticsApi);
const apiFactory = tester.get(ApiBlueprint.dataRefs.factory);
// Then the API's deps should be empty
expect(apiFactory.deps).toEqual({});
// And the returned instance should be an Analytics Implementation
const concreteImplementation = apiFactory.factory({});
expect(concreteImplementation).toHaveProperty('captureEvent');
// Invoking the API's captureEvent method should result in no errors
expect(() =>
(concreteImplementation as AnalyticsImplementation).captureEvent(
mockEvent,
),
).not.toThrow();
});
});
@@ -0,0 +1,82 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
analyticsApiRef,
AnalyticsBlueprint,
ApiBlueprint,
ApiRef,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
export const AnalyticsApi = ApiBlueprint.makeWithOverrides({
name: 'analytics',
inputs: {
analyticsImplementations: createExtensionInput([
AnalyticsBlueprint.dataRefs.factory,
]),
},
factory(originalFactory, { inputs }) {
// Pull out and aggregate deps from every implementation input into an
// object keyed by the apiRef ID to be passed to this API implementation as
// if they were its own deps.
const aggregatedDeps = inputs.analyticsImplementations
.flatMap<ApiRef<unknown>>(impls =>
Object.values(impls.get(AnalyticsBlueprint.dataRefs.factory).deps),
)
.reduce<{ [x: string]: ApiRef<unknown> }>((accum, ref) => {
accum[ref.id] = ref;
return accum;
}, {});
return originalFactory(defineParams =>
defineParams({
api: analyticsApiRef,
deps: aggregatedDeps,
factory: analyticsApiDeps => {
const actualApis = inputs.analyticsImplementations
.map(impl => impl.get(AnalyticsBlueprint.dataRefs.factory))
.map(({ factory, deps }) =>
factory(
// Reconstruct a deps argument to pass to this analytics
// implementation factory from those passed into ours.
Object.keys(deps).reduce<{ [x: string]: ApiRef<unknown> }>(
(accum, dep) => {
accum[dep] = analyticsApiDeps[
(deps as { [x: string]: ApiRef<unknown> })[dep].id
] as ApiRef<unknown>;
return accum;
},
{},
),
),
);
return {
captureEvent: event => {
actualApis.forEach(api => {
try {
api.captureEvent(event);
} catch {
/* ignored */
}
});
},
};
},
}),
);
},
});