diff --git a/.changeset/veka-fingrar-drar.md b/.changeset/veka-fingrar-drar.md new file mode 100644 index 0000000000..8f5281632d --- /dev/null +++ b/.changeset/veka-fingrar-drar.md @@ -0,0 +1,28 @@ +--- +'@backstage/core-app-api': patch +--- + +If you'd like to send analytics events to multiple implementations, you may now +do so using the `MultipleAnalyticsApi` implementation provided by this package. + +```tsx +import { MultipleAnalyticsApi } from '@backstage/core-app-api'; +import { + analyticsApiRef, + configApiRef, + storageApiRef, + identityApiRef, +} from '@internal/backstage/core-plugin-api'; +import { CustomAnalyticsApi } from '@internal/analytics'; +import { VendorAnalyticsApi } from '@vendor/analytics'; + +createApiFactory({ + api: analyticsApiRef, + deps: { configApi: configApiRef, identityApi: identityApiRef, storageApi: storageApiRef }, + factory: ({ configApi, identityApi, storageApi }) => + MultipleAnalyticsApi.withApis([ + VendorAnalyticsApi.fromConfig(configApi, { identityApi }), + CustomAnalyticsApi.fromConfig(configApi, { identityApi, storageApi }), + ]), +}), +``` diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 3cc1f4a8a8..9d528b6bef 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -409,6 +409,12 @@ export class MicrosoftAuth { static create(options: OAuthApiCreateOptions): typeof microsoftAuthApiRef.T; } +// @public +export class MultipleAnalyticsApi implements AnalyticsApi { + captureEvent(event: AnalyticsEvent): void; + static withApis(actualApis?: AnalyticsApi[]): MultipleAnalyticsApi; +} + // @public export class NoOpAnalyticsApi implements AnalyticsApi { // (undocumented) diff --git a/packages/core-app-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.test.ts b/packages/core-app-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.test.ts new file mode 100644 index 0000000000..e020c7b073 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2022 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 { MultipleAnalyticsApi } from './MultipleAnalyticsApi'; + +describe('MultipleAnalyticsApi', () => { + const analyticsApiOne = { captureEvent: jest.fn() }; + const analyticsApiTwo = { captureEvent: jest.fn() }; + const multipleApis = MultipleAnalyticsApi.withApis([ + analyticsApiOne, + analyticsApiTwo, + ]); + + const event = { + action: 'navivate', + subject: '/path', + context: { + extension: 'App', + pluginId: 'plugin', + routeRef: 'unknown', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('forwards events to all apis', () => { + // When an event is captured + multipleApis.captureEvent(event); + + // Then both underlying APIs should have received the event + expect(analyticsApiOne.captureEvent).toHaveBeenCalledTimes(1); + expect(analyticsApiOne.captureEvent).toHaveBeenCalledWith(event); + expect(analyticsApiTwo.captureEvent).toHaveBeenCalledTimes(1); + expect(analyticsApiTwo.captureEvent).toHaveBeenCalledWith(event); + }); + + it('forwards events to all apis even if one throws an error', () => { + // Given one underlying API that throws on capture + analyticsApiOne.captureEvent.mockImplementation(() => { + throw new Error('!!!'); + }); + + // When an event is captured + multipleApis.captureEvent(event); + + // Then the other underlying API should have still received the event + expect(analyticsApiTwo.captureEvent).toHaveBeenCalledTimes(1); + expect(analyticsApiTwo.captureEvent).toHaveBeenCalledWith(event); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.ts b/packages/core-app-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.ts new file mode 100644 index 0000000000..72f04dc242 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2022 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 { AnalyticsApi, AnalyticsEvent } from '@backstage/core-plugin-api'; + +/** + * An implementation of the AnalyticsApi that can be used to forward analytics + * events to multiple concrete implementations. + * + * @public + * + * @example + * + * ```jsx + * createApiFactory({ + * api: analyticsApiRef, + * deps: { configApi: configApiRef, identityApi: identityApiRef, storageApi: storageApiRef }, + * factory: ({ configApi, identityApi, storageApi }) => + * MultipleAnalyticsApi.withApis([ + * VendorAnalyticsApi.fromConfig(configApi, { identityApi }), + * CustomAnalyticsApi.fromConfig(configApi, { identityApi, storageApi }), + * ]), + * }); + * ``` + */ +export class MultipleAnalyticsApi implements AnalyticsApi { + private constructor(private readonly actualApis: AnalyticsApi[]) {} + + /** + * Create an AnalyticsApi implementation from an array of concrete + * implementations. + * + * @example + * + * ```jsx + * MultipleAnalyticsApi.withApis([ + * SomeAnalyticsApi.fromConfig(configApi), + * new CustomAnalyticsApi(), + * ]); + * ``` + */ + static withApis(actualApis: AnalyticsApi[] = []) { + return new MultipleAnalyticsApi(actualApis); + } + + /** + * Forward the event to all configured analytics API implementations. + */ + captureEvent(event: AnalyticsEvent): void { + this.actualApis.forEach(analyticsApi => { + /* eslint no-empty: ["error", { "allowEmptyCatch": true }] */ + try { + analyticsApi.captureEvent(event); + } catch {} + }); + } +} diff --git a/packages/core-app-api/src/apis/implementations/AnalyticsApi/index.ts b/packages/core-app-api/src/apis/implementations/AnalyticsApi/index.ts index 6bb27dd6d0..0a137ee422 100644 --- a/packages/core-app-api/src/apis/implementations/AnalyticsApi/index.ts +++ b/packages/core-app-api/src/apis/implementations/AnalyticsApi/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ +export { MultipleAnalyticsApi } from './MultipleAnalyticsApi'; export { NoOpAnalyticsApi } from './NoOpAnalyticsApi';