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