diff --git a/.changeset/mira-looking-ostrich.md b/.changeset/mira-looking-ostrich.md new file mode 100644 index 0000000000..2d75fe2ce8 --- /dev/null +++ b/.changeset/mira-looking-ostrich.md @@ -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), + }), +}); +``` diff --git a/.changeset/nej-inte-ostrich.md b/.changeset/nej-inte-ostrich.md new file mode 100644 index 0000000000..db97567b54 --- /dev/null +++ b/.changeset/nej-inte-ostrich.md @@ -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. diff --git a/docs/plugins/analytics.md b/docs/plugins/analytics.md index 0a6bac81a0..037eb3d92d 100644 --- a/docs/plugins/analytics.md +++ b/docs/plugins/analytics.md @@ -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 diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 5ea43c7449..30c7d0d612 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -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; +// @public +export const AnalyticsBlueprint: ExtensionBlueprint<{ + kind: 'analytics'; + name: undefined; + params: ( + params: AnalyticsImplementationFactory, + ) => ExtensionBlueprintParams>; + output: ConfigurableExtensionDataRef< + AnalyticsImplementationFactory<{}>, + 'core.analytics.factory', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + factory: ConfigurableExtensionDataRef< + AnalyticsImplementationFactory<{}>, + 'core.analytics.factory', + {} + >; + }; +}>; + // @public export const AnalyticsContext: (options: { attributes: Partial; @@ -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; + factory(deps: Deps): AnalyticsImplementation; +}; + // @public export type AnalyticsTracker = { captureEvent: ( diff --git a/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts index 15b09482b7..9752d0bd6f 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts @@ -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 = createApiRef({ diff --git a/packages/frontend-plugin-api/src/blueprints/AnalyticsBlueprint.test.ts b/packages/frontend-plugin-api/src/blueprints/AnalyticsBlueprint.test.ts new file mode 100644 index 0000000000..1618c38335 --- /dev/null +++ b/packages/frontend-plugin-api/src/blueprints/AnalyticsBlueprint.test.ts @@ -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", + } + `); + }); +}); diff --git a/packages/frontend-plugin-api/src/blueprints/AnalyticsBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/AnalyticsBlueprint.ts new file mode 100644 index 0000000000..8bd4c0526e --- /dev/null +++ b/packages/frontend-plugin-api/src/blueprints/AnalyticsBlueprint.ts @@ -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; + factory(deps: Deps): AnalyticsImplementation; +}; + +const factoryDataRef = + createExtensionDataRef().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: ( + params: AnalyticsImplementationFactory, + ) => createExtensionBlueprintParams(params as AnalyticsImplementationFactory), + *factory(params) { + yield factoryDataRef(params); + }, +}); diff --git a/packages/frontend-plugin-api/src/blueprints/index.ts b/packages/frontend-plugin-api/src/blueprints/index.ts index 047b007226..29aa410e80 100644 --- a/packages/frontend-plugin-api/src/blueprints/index.ts +++ b/packages/frontend-plugin-api/src/blueprints/index.ts @@ -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'; diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 70b1e39651..9ddf7df9ed 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -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; }>; '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, diff --git a/plugins/app/src/defaultApis.ts b/plugins/app/src/defaultApis.ts index c8f68232d2..b3d130fb09 100644 --- a/plugins/app/src/defaultApis.ts +++ b/plugins/app/src/defaultApis.ts @@ -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 => diff --git a/plugins/app/src/extensions/AnalyticsApi.test.ts b/plugins/app/src/extensions/AnalyticsApi.test.ts new file mode 100644 index 0000000000..814f41b616 --- /dev/null +++ b/plugins/app/src/extensions/AnalyticsApi.test.ts @@ -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(); + }); +}); diff --git a/plugins/app/src/extensions/AnalyticsApi.ts b/plugins/app/src/extensions/AnalyticsApi.ts new file mode 100644 index 0000000000..5e64e5abcf --- /dev/null +++ b/plugins/app/src/extensions/AnalyticsApi.ts @@ -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>(impls => + Object.values(impls.get(AnalyticsBlueprint.dataRefs.factory).deps), + ) + .reduce<{ [x: string]: ApiRef }>((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 }>( + (accum, dep) => { + accum[dep] = analyticsApiDeps[ + (deps as { [x: string]: ApiRef })[dep].id + ] as ApiRef; + return accum; + }, + {}, + ), + ), + ); + return { + captureEvent: event => { + actualApis.forEach(api => { + try { + api.captureEvent(event); + } catch { + /* ignored */ + } + }); + }, + }; + }, + }), + ); + }, +});