diff --git a/.changeset/blue-keys-do.md b/.changeset/blue-keys-do.md new file mode 100644 index 0000000000..c933bac0f2 --- /dev/null +++ b/.changeset/blue-keys-do.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': minor +--- + +**BREAKING**: Replace default plugin extension and plugin ids to be `app` instead of `root`. diff --git a/.changeset/gorgeous-pumas-draw.md b/.changeset/gorgeous-pumas-draw.md new file mode 100644 index 0000000000..2e94c61c63 --- /dev/null +++ b/.changeset/gorgeous-pumas-draw.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-analytics-module-newrelic-browser': minor +'@backstage/plugin-analytics-module-ga4': minor +'@backstage/plugin-analytics-module-ga': minor +'@backstage/core-compat-api': minor +--- + +Add support to the new analytics api. diff --git a/.changeset/old-papayas-shave.md b/.changeset/old-papayas-shave.md new file mode 100644 index 0000000000..1372b9ce85 --- /dev/null +++ b/.changeset/old-papayas-shave.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Wrap the root element with the analytics context to ensure it always exists for all extensions. diff --git a/.changeset/shaggy-trainers-rule.md b/.changeset/shaggy-trainers-rule.md new file mode 100644 index 0000000000..4140a400e1 --- /dev/null +++ b/.changeset/shaggy-trainers-rule.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': patch +--- + +Throw a more specific exception `NotImplementedError` when an API implementation cannot be found. diff --git a/packages/core-compat-api/api-report.md b/packages/core-compat-api/api-report.md index a7aae2b5b9..be80ca50e9 100644 --- a/packages/core-compat-api/api-report.md +++ b/packages/core-compat-api/api-report.md @@ -3,6 +3,10 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AnalyticsApi } from '@backstage/core-plugin-api'; +import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api'; +import { AnalyticsEvent } from '@backstage/core-plugin-api'; +import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { ExternalRouteRef as ExternalRouteRef_2 } from '@backstage/frontend-plugin-api'; @@ -51,6 +55,20 @@ export function convertLegacyRouteRefs< [KName in keyof TRefs]: ToNewRouteRef; }; +// @public +export class MultipleAnalyticsApi implements AnalyticsApi, AnalyticsApi_2 { + captureEvent(event: AnalyticsEvent | AnalyticsEvent_2): void; + static fromApis( + actualApis: (AnalyticsApi | AnalyticsApi_2)[], + ): MultipleAnalyticsApi; +} + +// @public +export class NoOpAnalyticsApi implements AnalyticsApi, AnalyticsApi_2 { + // (undocumented) + captureEvent(_event: AnalyticsEvent | AnalyticsEvent_2): void; +} + // @public export type ToNewRouteRef = T extends RouteRef diff --git a/packages/core-compat-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.test.ts b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.test.ts new file mode 100644 index 0000000000..8095ee0020 --- /dev/null +++ b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2024 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.fromApis([ + 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-compat-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.ts b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.ts new file mode 100644 index 0000000000..888474877d --- /dev/null +++ b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/MultipleAnalyticsApi.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2024 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'; +import { + AnalyticsApi as NewAnalyicsApi, + AnalyticsEvent as NewAnalyicsEvent, +} from '@backstage/frontend-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.fromApis([ + * VendorAnalyticsApi.fromConfig(configApi, { identityApi }), + * CustomAnalyticsApi.fromConfig(configApi, { identityApi, storageApi }), + * ]), + * }); + * ``` + */ +export class MultipleAnalyticsApi implements AnalyticsApi, NewAnalyicsApi { + private constructor( + private readonly actualApis: (AnalyticsApi | NewAnalyicsApi)[], + ) {} + + /** + * Create an AnalyticsApi implementation from an array of concrete + * implementations. + * + * @example + * + * ```jsx + * MultipleAnalyticsApi.fromApis([ + * SomeAnalyticsApi.fromConfig(configApi), + * new CustomAnalyticsApi(), + * ]); + * ``` + */ + static fromApis(actualApis: (AnalyticsApi | NewAnalyicsApi)[]) { + return new MultipleAnalyticsApi(actualApis); + } + + /** + * Forward the event to all configured analytics API implementations. + */ + captureEvent(event: AnalyticsEvent | NewAnalyicsEvent): void { + this.actualApis.forEach(analyticsApi => { + try { + analyticsApi.captureEvent(event as AnalyticsEvent & NewAnalyicsEvent); + } catch { + /* ignored */ + } + }); + } +} diff --git a/packages/core-compat-api/src/apis/implementations/AnalyticsApi/NoOpAnalyticsApi.ts b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/NoOpAnalyticsApi.ts new file mode 100644 index 0000000000..ad57258fdd --- /dev/null +++ b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/NoOpAnalyticsApi.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2024 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'; +import { + AnalyticsApi as NewAnalyicsApi, + AnalyticsEvent as NewAnalyicsEvent, +} from '@backstage/frontend-plugin-api'; + +/** + * Base implementation for the AnalyticsApi that does nothing. + * + * @public + */ +export class NoOpAnalyticsApi implements AnalyticsApi, NewAnalyicsApi { + captureEvent(_event: AnalyticsEvent | NewAnalyicsEvent): void {} +} diff --git a/packages/core-compat-api/src/apis/implementations/AnalyticsApi/index.ts b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/index.ts new file mode 100644 index 0000000000..c9e2008516 --- /dev/null +++ b/packages/core-compat-api/src/apis/implementations/AnalyticsApi/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2024 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. + */ + +export { MultipleAnalyticsApi } from './MultipleAnalyticsApi'; +export { NoOpAnalyticsApi } from './NoOpAnalyticsApi'; diff --git a/packages/core-compat-api/src/apis/implementations/index.ts b/packages/core-compat-api/src/apis/implementations/index.ts new file mode 100644 index 0000000000..82fc567355 --- /dev/null +++ b/packages/core-compat-api/src/apis/implementations/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 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. + */ + +export * from './AnalyticsApi'; diff --git a/packages/core-compat-api/src/apis/index.ts b/packages/core-compat-api/src/apis/index.ts new file mode 100644 index 0000000000..dd4cc2a930 --- /dev/null +++ b/packages/core-compat-api/src/apis/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 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. + */ + +export * from './implementations'; diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts index b632bf8a57..88e1892eac 100644 --- a/packages/core-compat-api/src/index.ts +++ b/packages/core-compat-api/src/index.ts @@ -15,6 +15,8 @@ */ export * from './compatWrapper'; +export * from './apis'; + export { convertLegacyApp } from './convertLegacyApp'; export { convertLegacyRouteRef, diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index d676260227..68d5ad68c3 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "@types/react": "^16.13.1 || ^17.0.0", diff --git a/packages/core-plugin-api/src/apis/system/useApi.tsx b/packages/core-plugin-api/src/apis/system/useApi.tsx index 03c4fa33ac..2bb1532525 100644 --- a/packages/core-plugin-api/src/apis/system/useApi.tsx +++ b/packages/core-plugin-api/src/apis/system/useApi.tsx @@ -17,6 +17,7 @@ import React, { PropsWithChildren } from 'react'; import { ApiRef, ApiHolder, TypesToApiRefs } from './types'; import { useVersionedContext } from '@backstage/version-bridge'; +import { NotImplementedError } from '@backstage/errors'; /** * React hook for retrieving {@link ApiHolder}, an API catalog. @@ -26,12 +27,12 @@ import { useVersionedContext } from '@backstage/version-bridge'; export function useApiHolder(): ApiHolder { const versionedHolder = useVersionedContext<{ 1: ApiHolder }>('api-context'); if (!versionedHolder) { - throw new Error('API context is not available'); + throw new NotImplementedError('API context is not available'); } const apiHolder = versionedHolder.atVersion(1); if (!apiHolder) { - throw new Error('ApiContext v1 not available'); + throw new NotImplementedError('ApiContext v1 not available'); } return apiHolder; } @@ -47,7 +48,7 @@ export function useApi(apiRef: ApiRef): T { const api = apiHolder.get(apiRef); if (!api) { - throw new Error(`No implementation available for ${apiRef}`); + throw new NotImplementedError(`No implementation available for ${apiRef}`); } return api; } @@ -73,7 +74,9 @@ export function withApis(apis: TypesToApiRefs) { const api = apiHolder.get(ref); if (!api) { - throw new Error(`No implementation available for ${ref}`); + throw new NotImplementedError( + `No implementation available for ${ref}`, + ); } impls[key] = api; } diff --git a/packages/frontend-app-api/src/extensions/App.tsx b/packages/frontend-app-api/src/extensions/App.tsx index 751a255fa7..82e7600178 100644 --- a/packages/frontend-app-api/src/extensions/App.tsx +++ b/packages/frontend-app-api/src/extensions/App.tsx @@ -14,7 +14,9 @@ * limitations under the License. */ +import React from 'react'; import { + ExtensionBoundary, coreExtensionData, createApiExtension, createComponentExtension, @@ -50,9 +52,13 @@ export const App = createExtension({ output: { root: coreExtensionData.reactElement, }, - factory({ inputs }) { + factory({ node, inputs }) { return { - root: inputs.root.output.element, + root: ( + + {inputs.root.output.element} + + ), }; }, }); diff --git a/packages/frontend-app-api/src/routing/RouteTracker.test.tsx b/packages/frontend-app-api/src/routing/RouteTracker.test.tsx index 0e66d0e50d..935449b697 100644 --- a/packages/frontend-app-api/src/routing/RouteTracker.test.tsx +++ b/packages/frontend-app-api/src/routing/RouteTracker.test.tsx @@ -211,8 +211,8 @@ describe('RouteTracker', () => { action: 'navigate', attributes: {}, context: { - extensionId: 'App', - pluginId: 'root', + extensionId: 'app', + pluginId: 'app', }, subject: '/not-routable-extension', value: undefined, @@ -221,8 +221,8 @@ describe('RouteTracker', () => { action: 'click', attributes: undefined, context: { - extensionId: 'App', - pluginId: 'root', + extensionId: 'app', + pluginId: 'app', }, subject: 'test', value: undefined, diff --git a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx index 8e78bdc8c4..bdfdef3005 100644 --- a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx +++ b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx @@ -35,8 +35,8 @@ describe('AnalyticsContext', () => { it('returns default values', () => { const { result } = renderHook(() => useAnalyticsContext()); expect(result.current).toEqual({ - extensionId: 'App', - pluginId: 'root', + extensionId: 'app', + pluginId: 'app', }); }); }); @@ -49,8 +49,8 @@ describe('AnalyticsContext', () => { , ); - expect(result.getByTestId('extension-id')).toHaveTextContent('App'); - expect(result.getByTestId('plugin-id')).toHaveTextContent('root'); + expect(result.getByTestId('extension-id')).toHaveTextContent('app'); + expect(result.getByTestId('plugin-id')).toHaveTextContent('app'); }); it('uses provided analytics context', () => { @@ -60,7 +60,7 @@ describe('AnalyticsContext', () => { , ); - expect(result.getByTestId('extension-id')).toHaveTextContent('App'); + expect(result.getByTestId('extension-id')).toHaveTextContent('app'); expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); }); diff --git a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx index 10ef475803..13047b41d7 100644 --- a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx +++ b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx @@ -37,8 +37,8 @@ export const useAnalyticsContext = (): AnalyticsContextValue => { // Provide a default value if no value exists. if (theContext === undefined) { return { - pluginId: 'root', - extensionId: 'App', + pluginId: 'app', + extensionId: 'app', }; } diff --git a/packages/frontend-plugin-api/src/analytics/types.ts b/packages/frontend-plugin-api/src/analytics/types.ts index 21347e2fc7..3e3a9ad5eb 100644 --- a/packages/frontend-plugin-api/src/analytics/types.ts +++ b/packages/frontend-plugin-api/src/analytics/types.ts @@ -25,9 +25,6 @@ export type CommonAnalyticsContext = { */ pluginId: string; - /** - * The nearest known parent extension where the event was captured. - */ /** * The nearest known parent extension where the event was captured. */ diff --git a/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx b/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx index beb16b1fbb..6dbcc81f11 100644 --- a/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx +++ b/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx @@ -53,8 +53,8 @@ describe('useAnalytics', () => { some: 'value', }, context: { - extensionId: 'App', - pluginId: 'root', + extensionId: 'app', + pluginId: 'app', }, }); }); diff --git a/packages/frontend-plugin-api/src/analytics/useAnalytics.tsx b/packages/frontend-plugin-api/src/analytics/useAnalytics.tsx index f3b5998de4..397906d1d1 100644 --- a/packages/frontend-plugin-api/src/analytics/useAnalytics.tsx +++ b/packages/frontend-plugin-api/src/analytics/useAnalytics.tsx @@ -23,8 +23,11 @@ import { Tracker } from './Tracker'; function useAnalyticsApi(): AnalyticsApi { try { return useApi(analyticsApiRef); - } catch { - return { captureEvent: () => {} }; + } catch (error) { + if (error.name === 'NotImplementedError') { + return { captureEvent: () => {} }; + } + throw error; } } diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index 9a8eb5b750..768352b76e 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -65,7 +65,7 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { // Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight const attributes = { extensionId: node.spec.id, - pluginId: node.spec.source?.id, + pluginId: node.spec.source?.id ?? 'app', }; return ( diff --git a/plugins/analytics-module-ga/api-report.md b/plugins/analytics-module-ga/api-report.md index 1846211e72..5404248938 100644 --- a/plugins/analytics-module-ga/api-report.md +++ b/plugins/analytics-module-ga/api-report.md @@ -4,7 +4,9 @@ ```ts import { AnalyticsApi } from '@backstage/core-plugin-api'; +import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api'; import { AnalyticsEvent } from '@backstage/core-plugin-api'; +import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { Config } from '@backstage/config'; import { IdentityApi } from '@backstage/core-plugin-api'; @@ -13,8 +15,8 @@ import { IdentityApi } from '@backstage/core-plugin-api'; export const analyticsModuleGA: BackstagePlugin<{}, {}>; // @public -export class GoogleAnalytics implements AnalyticsApi { - captureEvent(event: AnalyticsEvent): void; +export class GoogleAnalytics implements AnalyticsApi, AnalyticsApi_2 { + captureEvent(event: AnalyticsEvent | AnalyticsEvent_2): void; static fromConfig( config: Config, options?: { diff --git a/plugins/analytics-module-ga/package.json b/plugins/analytics-module-ga/package.json index 3d367a5889..cabff881ad 100644 --- a/plugins/analytics-module-ga/package.json +++ b/plugins/analytics-module-ga/package.json @@ -32,6 +32,7 @@ "@backstage/config": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", "react-ga": "^3.3.0" }, "peerDependencies": { diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts index 65e9256aa8..48e43c2a45 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.test.ts @@ -508,4 +508,133 @@ describe('GoogleAnalytics', () => { expect(lastData.queueTime).toBeUndefined(); }); }); + + describe('api backward compatibility', () => { + it('continue working with legacy App category', () => { + const api = GoogleAnalytics.fromConfig(basicValidConfig); + + expect(api.captureEvent).toBeDefined(); + + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + + let [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'pageview', + page: '/', + }); + + api.captureEvent({ + action: 'click', + subject: 'on something', + value: 42, + context, + }); + + [command, data] = ReactGA.testModeAPI.calls[2]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'event', + // expect to use the legacy default category + eventCategory: 'App', + eventAction: 'click', + eventLabel: 'on something', + eventValue: 42, + }); + }); + + it('use lowercase app as the new default category', () => { + const api = GoogleAnalytics.fromConfig(basicValidConfig); + + expect(api.captureEvent).toBeDefined(); + + api.captureEvent({ + action: 'navigate', + subject: '/', + context: { + ...context, + extensionId: '', + extension: '', + }, + }); + + let [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'pageview', + page: '/', + }); + + api.captureEvent({ + action: 'click', + subject: 'on something', + value: 42, + context: { + ...context, + extensionId: '', + extension: '', + }, + }); + + [command, data] = ReactGA.testModeAPI.calls[2]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'event', + // expect to use the new default category + eventCategory: 'App', + eventAction: 'click', + eventLabel: 'on something', + eventValue: 42, + }); + }); + + it('prioritize new context extension id over old extension property', () => { + const api = GoogleAnalytics.fromConfig(basicValidConfig); + + expect(api.captureEvent).toBeDefined(); + + api.captureEvent({ + action: 'navigate', + subject: '/', + context: { + ...context, + extensionId: 'app', + extension: '', + }, + }); + + let [command, data] = ReactGA.testModeAPI.calls[1]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'pageview', + page: '/', + }); + + api.captureEvent({ + action: 'click', + subject: 'on something', + value: 42, + context: { + ...context, + extensionId: 'page:index', + extension: '', + }, + }); + + [command, data] = ReactGA.testModeAPI.calls[2]; + expect(command).toBe('send'); + expect(data).toMatchObject({ + hitType: 'event', + // expect use the new context extension id + eventCategory: 'page:index', + eventAction: 'click', + eventLabel: 'on something', + eventValue: 42, + }); + }); + }); }); diff --git a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts index 17c5b26be1..d241cd9860 100644 --- a/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts +++ b/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts @@ -22,6 +22,11 @@ import { AnalyticsEventAttributes, IdentityApi, } from '@backstage/core-plugin-api'; +import { + AnalyticsApi as NewAnalyticsApi, + AnalyticsEvent as NewAnalyticsEvent, + AnalyticsContextValue as NewAnalyticsContextValue, +} from '@backstage/frontend-plugin-api'; import { Config } from '@backstage/config'; import { DeferredCapture } from '../../../util'; import { @@ -40,7 +45,7 @@ type CustomDimensionOrMetricConfig = { * Google Analytics API provider for the Backstage Analytics API. * @public */ -export class GoogleAnalytics implements AnalyticsApi { +export class GoogleAnalytics implements AnalyticsApi, NewAnalyticsApi { private readonly cdmConfig: CustomDimensionOrMetricConfig[]; private customUserIdTransform?: (userEntityRef: string) => Promise; private readonly capture: DeferredCapture; @@ -157,11 +162,18 @@ export class GoogleAnalytics implements AnalyticsApi { * pageview and the rest as custom events. All custom dimensions/metrics are * applied as they should be (set on pageview, merged object on events). */ - captureEvent(event: AnalyticsEvent) { + captureEvent(event: AnalyticsEvent | NewAnalyticsEvent) { const { context, action, subject, value, attributes } = event; const customMetadata = this.getCustomDimensionMetrics(context, attributes); - if (action === 'navigate' && context.extension === 'App') { + const extensionId = context.extensionId || context.extension; + const category = extensionId ? String(extensionId) : 'App'; + + // The legacy default extension was 'App' and the new one is 'app' + if ( + action === 'navigate' && + category.toLocaleLowerCase('en-US').startsWith('app') + ) { this.capture.pageview(subject, customMetadata); return; } @@ -184,7 +196,7 @@ export class GoogleAnalytics implements AnalyticsApi { } this.capture.event({ - category: context.extension || 'App', + category, action, label: subject, value, @@ -197,7 +209,7 @@ export class GoogleAnalytics implements AnalyticsApi { * Event Attributes, e.g. { dimension1: "some value", metric8: 42 } */ private getCustomDimensionMetrics( - context: AnalyticsContextValue, + context: AnalyticsContextValue | NewAnalyticsContextValue, attributes: AnalyticsEventAttributes = {}, ) { const customDimensionsMetrics: { [x: string]: string | number | boolean } = diff --git a/plugins/analytics-module-ga4/api-report.md b/plugins/analytics-module-ga4/api-report.md index 77f3094210..b8282e834a 100644 --- a/plugins/analytics-module-ga4/api-report.md +++ b/plugins/analytics-module-ga4/api-report.md @@ -4,13 +4,15 @@ ```ts import { AnalyticsApi } from '@backstage/core-plugin-api'; +import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api'; import { AnalyticsEvent } from '@backstage/core-plugin-api'; +import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api'; import { Config } from '@backstage/config'; import { IdentityApi } from '@backstage/core-plugin-api'; // @public -export class GoogleAnalytics4 implements AnalyticsApi { - captureEvent(event: AnalyticsEvent): void; +export class GoogleAnalytics4 implements AnalyticsApi, AnalyticsApi_2 { + captureEvent(event: AnalyticsEvent | AnalyticsEvent_2): void; static fromConfig( config: Config, options?: { diff --git a/plugins/analytics-module-ga4/package.json b/plugins/analytics-module-ga4/package.json index 97b610c2bf..89113efe6d 100644 --- a/plugins/analytics-module-ga4/package.json +++ b/plugins/analytics-module-ga4/package.json @@ -32,6 +32,7 @@ "@backstage/config": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", "react-ga4": "^2.0.0" }, "peerDependencies": { diff --git a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts index 16db7075b3..025462903b 100644 --- a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.test.ts @@ -492,4 +492,60 @@ describe('GoogleAnalytics4', () => { }); }); }); + + describe('api backward compatibility', () => { + it('continue working with legacy App category', () => { + const api = GoogleAnalytics4.fromConfig(basicValidConfig); + + expect(api.captureEvent).toBeDefined(); + + api.captureEvent({ + action: 'navigate', + subject: '/', + context, + }); + + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/', + category: 'App', + }); + }); + + it('use lowercase app as the new default category', () => { + const api = GoogleAnalytics4.fromConfig(basicValidConfig); + + expect(api.captureEvent).toBeDefined(); + + api.captureEvent({ + action: 'navigate', + subject: '/', + context: { ...context, extensionId: '', extension: '' }, + }); + + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/', + category: 'App', + }); + }); + + it('prioritize new context extension id over old extension property', () => { + const api = GoogleAnalytics4.fromConfig(basicValidConfig); + + expect(api.captureEvent).toBeDefined(); + + api.captureEvent({ + action: 'navigate', + subject: '/', + context: { ...context, extensionId: 'app', extension: '' }, + }); + + expect(fnEvent).toHaveBeenCalledWith('page_view', { + action: 'page_view', + label: '/', + category: 'app', + }); + }); + }); }); diff --git a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts index 9abf4719c6..43ff47b84e 100644 --- a/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts +++ b/plugins/analytics-module-ga4/src/apis/implementations/AnalyticsApi/GoogleAnalytics4.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import ReactGA from 'react-ga4'; import { AnalyticsApi, @@ -21,6 +22,11 @@ import { AnalyticsEvent, IdentityApi, } from '@backstage/core-plugin-api'; +import { + AnalyticsApi as NewAnalyticsApi, + AnalyticsEvent as NewAnalyticsEvent, + AnalyticsContextValue as NewAnalyticsContextValue, +} from '@backstage/frontend-plugin-api'; import { Config } from '@backstage/config'; import { DeferredCapture } from '../../../util/DeferredCapture'; @@ -28,7 +34,7 @@ import { DeferredCapture } from '../../../util/DeferredCapture'; * Google Analytics API provider for the Backstage Analytics API. * @public */ -export class GoogleAnalytics4 implements AnalyticsApi { +export class GoogleAnalytics4 implements AnalyticsApi, NewAnalyticsApi { private readonly customUserIdTransform?: ( userEntityRef: string, ) => Promise; @@ -157,17 +163,24 @@ export class GoogleAnalytics4 implements AnalyticsApi { * applied as they should be (set on pageview, merged object on events). * @param event - AnalyticsEvent type captured */ - captureEvent(event: AnalyticsEvent) { + captureEvent(event: AnalyticsEvent | NewAnalyticsEvent) { const { context, action, subject, value, attributes } = event; const customEventData = this.setEventParameters(context, attributes); if (this.contentGroupBy) { customEventData.content_group = context[this.contentGroupBy]!; } - if (action === 'navigate' && context.extension === 'App') { + const extensionId = context.extensionId || context.extension; + const category = extensionId ? String(extensionId) : 'App'; + + // The legacy default extension was 'App' and the new one is 'app' + if ( + action === 'navigate' && + category.toLocaleLowerCase('en-US').startsWith('app') + ) { this.capture.event( { - category: context.extension || 'App', + category, action: 'page_view', label: subject, value, @@ -183,7 +196,7 @@ export class GoogleAnalytics4 implements AnalyticsApi { this.capture.event( { - category: context.extension || 'App', + category, action, label: subject, value, @@ -199,7 +212,7 @@ export class GoogleAnalytics4 implements AnalyticsApi { * @param attributes additional analytics event attributes */ private setEventParameters( - context: AnalyticsContextValue, + context: AnalyticsContextValue | NewAnalyticsContextValue, attributes: AnalyticsEventAttributes = {}, ) { const customEventParameters: { diff --git a/plugins/analytics-module-newrelic-browser/api-report.md b/plugins/analytics-module-newrelic-browser/api-report.md index 7556bae317..09fbf8f0f3 100644 --- a/plugins/analytics-module-newrelic-browser/api-report.md +++ b/plugins/analytics-module-newrelic-browser/api-report.md @@ -4,14 +4,16 @@ ```ts import { AnalyticsApi } from '@backstage/core-plugin-api'; +import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api'; import { AnalyticsEvent } from '@backstage/core-plugin-api'; +import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api'; import { Config } from '@backstage/config'; import { IdentityApi } from '@backstage/core-plugin-api'; // @public -export class NewRelicBrowser implements AnalyticsApi { +export class NewRelicBrowser implements AnalyticsApi, AnalyticsApi_2 { // (undocumented) - captureEvent(event: AnalyticsEvent): void; + captureEvent(event: AnalyticsEvent | AnalyticsEvent_2): void; // (undocumented) static fromConfig( config: Config, diff --git a/plugins/analytics-module-newrelic-browser/package.json b/plugins/analytics-module-newrelic-browser/package.json index 0815b62b6c..44610dd026 100644 --- a/plugins/analytics-module-newrelic-browser/package.json +++ b/plugins/analytics-module-newrelic-browser/package.json @@ -26,6 +26,7 @@ "@backstage/config": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", "@newrelic/browser-agent": "^1.236.0" }, "peerDependencies": { diff --git a/plugins/analytics-module-newrelic-browser/src/apis/implementations/AnalyticsApi/NewRelicBrowser.ts b/plugins/analytics-module-newrelic-browser/src/apis/implementations/AnalyticsApi/NewRelicBrowser.ts index f2f35b5765..d65d02db64 100644 --- a/plugins/analytics-module-newrelic-browser/src/apis/implementations/AnalyticsApi/NewRelicBrowser.ts +++ b/plugins/analytics-module-newrelic-browser/src/apis/implementations/AnalyticsApi/NewRelicBrowser.ts @@ -19,6 +19,10 @@ import { IdentityApi, AnalyticsEvent, } from '@backstage/core-plugin-api'; +import { + AnalyticsApi as NewAnalyicsApi, + AnalyticsEvent as NewAnalyticsEvent, +} from '@backstage/frontend-plugin-api'; import { BrowserAgent } from '@newrelic/browser-agent/loaders/browser-agent'; import type { setAPI } from '@newrelic/browser-agent/loaders/api/api'; @@ -37,7 +41,7 @@ type NewRelicBrowserOptions = { * New Relic Browser API provider for the Backstage Analytics API. * @public */ -export class NewRelicBrowser implements AnalyticsApi { +export class NewRelicBrowser implements AnalyticsApi, NewAnalyicsApi { private readonly agent: NewRelicAPI; private constructor( @@ -121,9 +125,17 @@ export class NewRelicBrowser implements AnalyticsApi { ); } - captureEvent(event: AnalyticsEvent) { + captureEvent(event: AnalyticsEvent | NewAnalyticsEvent) { const { context, action, subject, value, attributes } = event; - if (action === 'navigate' && context.extension === 'App') { + + const extensionId = context.extensionId || context.extension; + const category = extensionId ? String(extensionId) : 'App'; + + // The legacy default extension was 'App' and the new one is 'app' + if ( + action === 'navigate' && + category.toLocaleLowerCase('en-US').startsWith('app') + ) { const interaction = this.agent.interaction(); interaction.setName(subject); if (value) { diff --git a/yarn.lock b/yarn.lock index 786f6a31f4..91a5836b28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3896,6 +3896,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/core-app-api": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" @@ -4312,6 +4313,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@testing-library/dom": ^9.0.0 "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0 @@ -4334,6 +4336,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@testing-library/dom": ^9.0.0 "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0 @@ -4355,6 +4358,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@newrelic/browser-agent": ^1.236.0 "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0