From 6e32443ebd473ff37253801537aff3ab0e82ba56 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 8 Nov 2023 09:58:30 +0100 Subject: [PATCH 1/8] feat: copy analytics context files Signed-off-by: Camila Belo --- .../src/analytics/AnalyticsContext.test.tsx | 85 +++++ .../src/analytics/AnalyticsContext.tsx | 109 +++++++ .../src/analytics/Tracker.test.ts | 306 ++++++++++++++++++ .../src/analytics/Tracker.ts | 180 +++++++++++ .../src/analytics/index.ts | 19 ++ .../src/analytics/types.ts | 46 +++ .../src/analytics/useAnalytics.test.tsx | 67 ++++ .../src/analytics/useAnalytics.tsx | 57 ++++ .../src/apis/definitions/AnalyticsApi.ts | 131 ++++++++ .../src/apis/definitions/index.ts | 1 + packages/frontend-plugin-api/src/index.ts | 1 + 11 files changed, 1002 insertions(+) create mode 100644 packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx create mode 100644 packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx create mode 100644 packages/frontend-plugin-api/src/analytics/Tracker.test.ts create mode 100644 packages/frontend-plugin-api/src/analytics/Tracker.ts create mode 100644 packages/frontend-plugin-api/src/analytics/index.ts create mode 100644 packages/frontend-plugin-api/src/analytics/types.ts create mode 100644 packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx create mode 100644 packages/frontend-plugin-api/src/analytics/useAnalytics.tsx create mode 100644 packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts diff --git a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx new file mode 100644 index 0000000000..c29ec5b3ca --- /dev/null +++ b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2021 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 React from 'react'; +import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import { AnalyticsContext, useAnalyticsContext } from './AnalyticsContext'; + +const AnalyticsSpy = () => { + const context = useAnalyticsContext(); + return ( + <> +
{context.routeRef}
+
{context.pluginId}
+
{context.extension}
+
{context.custom}
+ + ); +}; + +describe('AnalyticsContext', () => { + describe('useAnalyticsContext', () => { + it('returns default values', () => { + const { result } = renderHook(() => useAnalyticsContext()); + expect(result.current).toEqual({ + extension: 'App', + pluginId: 'root', + routeRef: 'unknown', + }); + }); + }); + + describe('AnalyticsContext', () => { + it('uses default analytics context', () => { + const result = render( + + + , + ); + + expect(result.getByTestId('extension')).toHaveTextContent('App'); + expect(result.getByTestId('plugin-id')).toHaveTextContent('root'); + expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); + }); + + it('uses provided analytics context', () => { + const result = render( + + + , + ); + + expect(result.getByTestId('extension')).toHaveTextContent('App'); + expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); + expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); + }); + + it('uses nested analytics context', () => { + const result = render( + + + + + , + ); + + expect(result.getByTestId('extension')).toHaveTextContent('AnalyticsSpy'); + expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); + expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); + }); + }); +}); diff --git a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx new file mode 100644 index 0000000000..105be8e2b5 --- /dev/null +++ b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2021 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 { + createVersionedContext, + createVersionedValueMap, +} from '@backstage/version-bridge'; +import React, { ReactNode, useContext } from 'react'; +import { AnalyticsContextValue } from './types'; + +const AnalyticsReactContext = createVersionedContext<{ + 1: AnalyticsContextValue; +}>('analytics-context'); + +/** + * A "private" (to this package) hook that enables context inheritance and a + * way to read Analytics Context values at event capture-time. + * + * @internal + */ +export const useAnalyticsContext = (): AnalyticsContextValue => { + const theContext = useContext(AnalyticsReactContext); + + // Provide a default value if no value exists. + if (theContext === undefined) { + return { + routeRef: 'unknown', + pluginId: 'root', + extension: 'App', + }; + } + + // This should probably never happen, but check for it. + const theValue = theContext.atVersion(1); + if (theValue === undefined) { + throw new Error('No context found for version 1.'); + } + + return theValue; +}; + +/** + * Provides components in the child react tree an Analytics Context, ensuring + * all analytics events captured within the context have relevant attributes. + * + * @remarks + * + * Analytics contexts are additive, meaning the context ultimately emitted with + * an event is the combination of all contexts in the parent tree. + * + * @public + */ +export const AnalyticsContext = (options: { + attributes: Partial; + children: ReactNode; +}) => { + const { attributes, children } = options; + + const parentValues = useAnalyticsContext(); + const combinedValue = { + ...parentValues, + ...attributes, + }; + + const versionedCombinedValue = createVersionedValueMap({ 1: combinedValue }); + return ( + + {children} + + ); +}; + +/** + * Returns an HOC wrapping the provided component in an Analytics context with + * the given values. + * + * @param Component - Component to be wrapped with analytics context attributes + * @param values - Analytics context key/value pairs. + * @internal + */ +export function withAnalyticsContext( + Component: React.ComponentType, + values: AnalyticsContextValue, +) { + const ComponentWithAnalyticsContext = (props: TProps) => { + return ( + + + + ); + }; + ComponentWithAnalyticsContext.displayName = `WithAnalyticsContext(${ + Component.displayName || Component.name || 'Component' + })`; + return ComponentWithAnalyticsContext; +} diff --git a/packages/frontend-plugin-api/src/analytics/Tracker.test.ts b/packages/frontend-plugin-api/src/analytics/Tracker.test.ts new file mode 100644 index 0000000000..e0e3a87f6d --- /dev/null +++ b/packages/frontend-plugin-api/src/analytics/Tracker.test.ts @@ -0,0 +1,306 @@ +/* + * Copyright 2023 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 { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; +import { Tracker, routableExtensionRenderedEvent } from './Tracker'; + +describe('Tracker', () => { + const defaultContext = { + routeRef: 'unknown', + pluginId: 'root', + extension: 'App', + }; + const globalEvents = getOrCreateGlobalSingleton( + 'core-plugin-api:analytics-tracker-events', + () => ({}), + ); + const analyticsApiSpy = { + captureEvent: jest.fn(), + }; + let trackerUnderTest: Tracker; + + beforeEach(() => { + // Reset mocks and global state + jest.resetAllMocks(); + globalEvents.mostRecentGatheredNavigation = undefined; + globalEvents.mostRecentRoutableExtensionRender = undefined; + + // Set up a new tracker to test. + trackerUnderTest = new Tracker(analyticsApiSpy); + }); + + it('captures simple event with default context', () => { + trackerUnderTest.captureEvent('click', 'test 1'); + + expect(analyticsApiSpy.captureEvent).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'click', + subject: 'test 1', + context: defaultContext, + }), + ); + }); + + it('captures simple event with set context', () => { + trackerUnderTest.setContext({ + routeRef: 'catalog:entity', + pluginId: 'catalog', + extension: 'App', + }); + trackerUnderTest.captureEvent('click', 'test 2', { + value: 2, + attributes: { to: '/page-2' }, + }); + + expect(analyticsApiSpy.captureEvent).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'click', + subject: 'test 2', + value: 2, + attributes: { to: '/page-2' }, + context: { + routeRef: 'catalog:entity', + pluginId: 'catalog', + extension: 'App', + }, + }), + ); + }); + + describe('accurate navigate events', () => { + it('never captures _routeNodeType context key on navigate event', () => { + trackerUnderTest.setContext({ + routeRef: 'catalog:entity', + pluginId: 'catalog', + extension: 'App', + _routeNodeType: 'mounted', + }); + trackerUnderTest.captureEvent('navigate', '/page-3'); + + const receivedContext = + analyticsApiSpy.captureEvent.mock.calls[0][0].context; + expect(receivedContext.pluginId).toBe('catalog'); + expect(receivedContext._routeNodeType).toBe(undefined); + }); + + it('never immediately captures navigate event with _routeNodeType "gathered"', () => { + trackerUnderTest.setContext({ + ...defaultContext, + _routeNodeType: 'gathered', + }); + trackerUnderTest.captureEvent('navigate', '/page-4'); + + expect(analyticsApiSpy.captureEvent).not.toHaveBeenCalled(); + }); + + it('never captures "routable-extension-rendered" events', () => { + trackerUnderTest.captureEvent(routableExtensionRenderedEvent, ''); + + expect(analyticsApiSpy.captureEvent).not.toHaveBeenCalled(); + }); + + it('captures deferred navigate event with expected context', () => { + // User navigates to a gathered mountpoint with multiple plugins + trackerUnderTest.setContext({ + ...defaultContext, + _routeNodeType: 'gathered', + }); + trackerUnderTest.captureEvent('navigate', '/page-5'); + + // A routable extension is rendered with specific plugin context + trackerUnderTest.setContext({ + routeRef: 'some:ref', + pluginId: 'some-plugin', + extension: 'App', + }); + trackerUnderTest.captureEvent(routableExtensionRenderedEvent, ''); + + // A non-navigate event + trackerUnderTest.captureEvent('click', 'test 5'); + + expect(analyticsApiSpy.captureEvent).toHaveBeenCalledTimes(2); + expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + action: 'navigate', + subject: '/page-5', + context: { + routeRef: 'some:ref', + pluginId: 'some-plugin', + extension: 'App', + }, + }), + ); + expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + action: 'click', + subject: 'test 5', + context: { + routeRef: 'some:ref', + pluginId: 'some-plugin', + extension: 'App', + }, + }), + ); + }); + + it('captures deferred navigate event with expected context when second event is also deferrable', () => { + // User navigates to a gathered mountpoint with multiple plugins + trackerUnderTest.setContext({ + ...defaultContext, + _routeNodeType: 'gathered', + }); + trackerUnderTest.captureEvent('navigate', '/page-6'); + + // A routable extension is rendered with specific plugin context + trackerUnderTest.setContext({ + routeRef: 'another:ref', + pluginId: 'another-plugin', + extension: 'App', + }); + trackerUnderTest.captureEvent(routableExtensionRenderedEvent, ''); + + // User navigates to another gathered mountpoint with multiple plugins + trackerUnderTest.setContext({ + ...defaultContext, + _routeNodeType: 'gathered', + }); + trackerUnderTest.captureEvent('navigate', '/page-6-2'); + + expect(analyticsApiSpy.captureEvent).toHaveBeenCalledTimes(1); + expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + action: 'navigate', + subject: '/page-6', + context: { + routeRef: 'another:ref', + pluginId: 'another-plugin', + extension: 'App', + }, + }), + ); + }); + + it('captures deferred navigate event with default context when no extension is rendered in between', () => { + // User navigates to a gathered mountpoint with multiple plugins + trackerUnderTest.setContext({ + ...defaultContext, + _routeNodeType: 'gathered', + }); + trackerUnderTest.captureEvent('navigate', '/page-7'); + + // A non-navigate event with no routable extension render in between + trackerUnderTest.captureEvent('click', 'test 7'); + + expect(analyticsApiSpy.captureEvent).toHaveBeenCalledTimes(2); + expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + action: 'navigate', + subject: '/page-7', + context: defaultContext, + }), + ); + expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + action: 'click', + subject: 'test 7', + context: defaultContext, + }), + ); + }); + + it('captures deferred navigate event with expected context across separate trackers', () => { + // User navigates to a gathered mountpoint with multiple plugins + trackerUnderTest.setContext({ + ...defaultContext, + _routeNodeType: 'gathered', + }); + trackerUnderTest.captureEvent('navigate', '/page-8'); + + // A routable extension is rendered with specific plugin context and + // captured via a separate tracker instance. + const anotherTracker = new Tracker(analyticsApiSpy, { + routeRef: 'the:ref', + pluginId: 'the-plugin', + extension: 'App', + }); + anotherTracker.captureEvent(routableExtensionRenderedEvent, ''); + + // A non-navigate event is captured + const aThirdTracker = new Tracker(analyticsApiSpy); + aThirdTracker.captureEvent('click', 'test 8'); + + expect(analyticsApiSpy.captureEvent).toHaveBeenCalledTimes(2); + expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + action: 'navigate', + subject: '/page-8', + context: { + routeRef: 'the:ref', + pluginId: 'the-plugin', + extension: 'App', + }, + }), + ); + expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + action: 'click', + subject: 'test 8', + }), + ); + }); + + it('replaces extension context with "App" when capturing deferred events', () => { + // User navigates to a gathered mountpoint with multiple plugins + trackerUnderTest.setContext({ + ...defaultContext, + _routeNodeType: 'gathered', + }); + trackerUnderTest.captureEvent('navigate', '/page-9'); + + // A routable extension is rendered with specific plugin context + trackerUnderTest.setContext({ + routeRef: 'very:ref', + pluginId: 'very-plugin', + extension: 'ShouldBeReplaced', + }); + trackerUnderTest.captureEvent(routableExtensionRenderedEvent, ''); + + // A non-navigate event + trackerUnderTest.captureEvent('click', 'test 9'); + + // Extension context should have been replaced with just "App" + expect(analyticsApiSpy.captureEvent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + action: 'navigate', + subject: '/page-9', + context: { + routeRef: 'very:ref', + pluginId: 'very-plugin', + extension: 'App', + }, + }), + ); + }); + }); +}); diff --git a/packages/frontend-plugin-api/src/analytics/Tracker.ts b/packages/frontend-plugin-api/src/analytics/Tracker.ts new file mode 100644 index 0000000000..130410e2a2 --- /dev/null +++ b/packages/frontend-plugin-api/src/analytics/Tracker.ts @@ -0,0 +1,180 @@ +/* + * Copyright 2021 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 { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; +import { + AnalyticsApi, + AnalyticsEventAttributes, + AnalyticsTracker, +} from '../apis'; +import { AnalyticsContextValue } from './'; + +type TempGlobalEvents = { + /** + * Stores the most recent "gathered" mountpoint navigation. + */ + mostRecentGatheredNavigation?: { + action: string; + subject: string; + value?: number; + attributes?: AnalyticsEventAttributes; + context: AnalyticsContextValue; + }; + /** + * Stores the most recent routable extension render. + */ + mostRecentRoutableExtensionRender?: { + context: AnalyticsContextValue; + }; + /** + * Tracks whether or not a beforeunload event listener has already been + * registered. + */ + beforeUnloadRegistered: boolean; +}; + +/** + * Temporary global store for select event data. Used to make `navigate` events + * more accurate when gathered mountpoints are used. + */ +const globalEvents = getOrCreateGlobalSingleton( + 'core-plugin-api:analytics-tracker-events', + () => ({ + mostRecentGatheredNavigation: undefined, + mostRecentRoutableExtensionRender: undefined, + beforeUnloadRegistered: false, + }), +); + +/** + * Internal-only event representing when a routable extension is rendered. + */ +export const routableExtensionRenderedEvent = '_ROUTABLE-EXTENSION-RENDERED'; + +export class Tracker implements AnalyticsTracker { + constructor( + private readonly analyticsApi: AnalyticsApi, + private context: AnalyticsContextValue = { + routeRef: 'unknown', + pluginId: 'root', + extension: 'App', + }, + ) { + // Only register a single beforeunload event across all trackers. + if (!globalEvents.beforeUnloadRegistered) { + // Before the page unloads, attempt to capture any deferred navigation + // events that haven't yet been captured. + addEventListener( + 'beforeunload', + () => { + if (globalEvents.mostRecentGatheredNavigation) { + this.analyticsApi.captureEvent({ + ...globalEvents.mostRecentGatheredNavigation, + ...globalEvents.mostRecentRoutableExtensionRender, + }); + globalEvents.mostRecentGatheredNavigation = undefined; + globalEvents.mostRecentRoutableExtensionRender = undefined; + } + }, + { once: true, passive: true }, + ); + + // Prevent duplicate handlers from being registered. + globalEvents.beforeUnloadRegistered = true; + } + } + + setContext(context: AnalyticsContextValue) { + this.context = context; + } + + captureEvent( + action: string, + subject: string, + { + value, + attributes, + }: { value?: number; attributes?: AnalyticsEventAttributes } = {}, + ) { + // Never pass internal "_routeNodeType" context value. + const { _routeNodeType, ...context } = this.context; + + // Never fire the special "_routable-extension-rendered" internal event. + if (action === routableExtensionRenderedEvent) { + // But keep track of it if we're delaying a `navigate` event for a + // a gathered route node type. + if (globalEvents.mostRecentGatheredNavigation) { + globalEvents.mostRecentRoutableExtensionRender = { + context: { + ...context, + extension: 'App', + }, + }; + } + return; + } + + // If we are about to fire a real event, and we have an un-fired gathered + // mountpoint navigation on the global store, we need to fire the navigate + // event first, so this real event happens accurately after the navigation. + if (globalEvents.mostRecentGatheredNavigation) { + try { + this.analyticsApi.captureEvent({ + ...globalEvents.mostRecentGatheredNavigation, + ...globalEvents.mostRecentRoutableExtensionRender, + }); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Error during analytics event capture. %o', e); + } + + // Clear the global stores. + globalEvents.mostRecentGatheredNavigation = undefined; + globalEvents.mostRecentRoutableExtensionRender = undefined; + } + + // Never directly fire a navigation event on a gathered route with default + // contextual details. + if ( + action === 'navigate' && + _routeNodeType === 'gathered' && + context.pluginId === 'root' + ) { + // Instead, set it on the global store. + globalEvents.mostRecentGatheredNavigation = { + action, + subject, + value, + attributes, + context, + }; + return; + } + + try { + this.analyticsApi.captureEvent({ + action, + subject, + value, + attributes, + context, + }); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Error during analytics event capture. %o', e); + } + } +} diff --git a/packages/frontend-plugin-api/src/analytics/index.ts b/packages/frontend-plugin-api/src/analytics/index.ts new file mode 100644 index 0000000000..651e8e105e --- /dev/null +++ b/packages/frontend-plugin-api/src/analytics/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 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 { AnalyticsContext } from './AnalyticsContext'; +export type { AnalyticsContextValue, CommonAnalyticsContext } from './types'; +export { useAnalytics } from './useAnalytics'; diff --git a/packages/frontend-plugin-api/src/analytics/types.ts b/packages/frontend-plugin-api/src/analytics/types.ts new file mode 100644 index 0000000000..ed5dcd588d --- /dev/null +++ b/packages/frontend-plugin-api/src/analytics/types.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2021 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. + */ + +/** + * Common analytics context attributes. + * + * @public + */ +export type CommonAnalyticsContext = { + /** + * The nearest known parent plugin where the event was captured. + */ + pluginId: string; + + /** + * The ID of the routeRef that was active when the event was captured. + */ + routeRef: string; + + /** + * The nearest known parent extension where the event was captured. + */ + extension: string; +}; + +/** + * Analytics context envelope. + * + * @public + */ +export type AnalyticsContextValue = CommonAnalyticsContext & { + [param in string]: string | boolean | number | undefined; +}; diff --git a/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx b/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx new file mode 100644 index 0000000000..e83826991a --- /dev/null +++ b/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2021 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 { renderHook } from '@testing-library/react'; +import { useAnalytics } from './useAnalytics'; +import { useApi } from '@backstage/core-plugin-api'; + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useApi: jest.fn(), +})); + +const mocked = (f: Function) => f as jest.Mock; + +describe('useAnalytics', () => { + it('returns tracker with no implementation defined', () => { + // Simulate useApi() throwing an error. + mocked(useApi).mockImplementation(() => { + throw new Error(); + }); + + // Result should still have a captureEvent method. + const { result } = renderHook(() => useAnalytics()); + expect(result.current.captureEvent).toBeDefined(); + }); + + it('returns tracker from defined analytics api', () => { + const captureEvent = jest.fn(); + + // Simulate useApi returning a valid tracker. + mocked(useApi).mockReturnValue({ captureEvent }); + + // Calling the captureEvent method of the underlying implementation should + // pass along the given event as well as the default context. + const { result } = renderHook(() => useAnalytics()); + result.current.captureEvent('an action', 'a subject', { + value: 42, + attributes: { some: 'value' }, + }); + expect(captureEvent).toHaveBeenCalledWith({ + action: 'an action', + subject: 'a subject', + value: 42, + attributes: { + some: 'value', + }, + context: { + extension: 'App', + pluginId: 'root', + routeRef: 'unknown', + }, + }); + }); +}); diff --git a/packages/frontend-plugin-api/src/analytics/useAnalytics.tsx b/packages/frontend-plugin-api/src/analytics/useAnalytics.tsx new file mode 100644 index 0000000000..f3b5998de4 --- /dev/null +++ b/packages/frontend-plugin-api/src/analytics/useAnalytics.tsx @@ -0,0 +1,57 @@ +/* + * Copyright 2021 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 { useApi } from '@backstage/core-plugin-api'; +import { useAnalyticsContext } from './AnalyticsContext'; +import { analyticsApiRef, AnalyticsTracker, AnalyticsApi } from '../apis'; +import { useRef } from 'react'; +import { Tracker } from './Tracker'; + +function useAnalyticsApi(): AnalyticsApi { + try { + return useApi(analyticsApiRef); + } catch { + return { captureEvent: () => {} }; + } +} + +/** + * Gets a pre-configured analytics tracker. + * + * @public + */ +export function useAnalytics(): AnalyticsTracker { + const trackerRef = useRef(null); + const context = useAnalyticsContext(); + // Our goal is to make this API truly optional for any/all consuming code + // (including tests). This hook runs last to ensure hook order is, as much as + // possible, maintained. + const analyticsApi = useAnalyticsApi(); + + function getTracker(): Tracker { + if (trackerRef.current === null) { + trackerRef.current = new Tracker(analyticsApi); + } + return trackerRef.current; + } + + const tracker = getTracker(); + // this is not ideal, but it allows to memoize the tracker + // without explicitly set the context as dependency. + tracker.setContext(context); + + return tracker; +} diff --git a/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts new file mode 100644 index 0000000000..15b09482b7 --- /dev/null +++ b/packages/frontend-plugin-api/src/apis/definitions/AnalyticsApi.ts @@ -0,0 +1,131 @@ +/* + * Copyright 2021 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 { ApiRef, createApiRef } from '@backstage/core-plugin-api'; +import { AnalyticsContextValue } from '../../analytics/types'; + +/** + * Represents an event worth tracking in an analytics system that could inform + * how users of a Backstage instance are using its features. + * + * @public + */ +export type AnalyticsEvent = { + /** + * A string that identifies the event being tracked by the type of action the + * event represents. Be careful not to encode extra metadata in this string + * that should instead be placed in the Analytics Context or attributes. + * Examples include: + * + * - view + * - click + * - filter + * - search + * - hover + * - scroll + */ + action: string; + + /** + * A string that uniquely identifies the object that the action is being + * taken on. Examples include: + * + * - The path of the page viewed + * - The url of the link clicked + * - The value that was filtered by + * - The text that was searched for + */ + subject: string; + + /** + * An optional numeric value relevant to the event that could be aggregated + * by analytics tools. Examples include: + * + * - The index or position of the clicked element in an ordered list + * - The percentage of an element that has been scrolled through + * - The amount of time that has elapsed since a fixed point + * - A satisfaction score on a fixed scale + */ + value?: number; + + /** + * Optional, additional attributes (representing dimensions or metrics) + * specific to the event that could be forwarded on to analytics systems. + */ + attributes?: AnalyticsEventAttributes; + + /** + * Contextual metadata relating to where the event was captured and by whom. + * This could include information about the route, plugin, or extension in + * which an event was captured. + */ + context: AnalyticsContextValue; +}; + +/** + * A structure allowing other arbitrary metadata to be provided by analytics + * event emitters. + * + * @public + */ +export type AnalyticsEventAttributes = { + [attribute in string]: string | boolean | number; +}; + +/** + * Represents a tracker with methods that can be called to track events in a + * configured analytics service. + * + * @public + */ +export type AnalyticsTracker = { + captureEvent: ( + action: string, + subject: string, + options?: { + value?: number; + attributes?: AnalyticsEventAttributes; + }, + ) => void; +}; + +/** + * The Analytics API is 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 AnalyticsApi = { + /** + * Primary event handler responsible for compiling and forwarding events to + * an analytics system. + */ + captureEvent(event: AnalyticsEvent): void; +}; + +/** + * The API reference of {@link AnalyticsApi}. + * + * @public + */ +export const analyticsApiRef: ApiRef = createApiRef({ + id: 'core.analytics', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts index 420942cb19..8791912e4a 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/index.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts @@ -42,3 +42,4 @@ export * from './FetchApi'; export * from './IdentityApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; +export * from './AnalyticsApi'; diff --git a/packages/frontend-plugin-api/src/index.ts b/packages/frontend-plugin-api/src/index.ts index 3248e79945..10f89b81b5 100644 --- a/packages/frontend-plugin-api/src/index.ts +++ b/packages/frontend-plugin-api/src/index.ts @@ -20,6 +20,7 @@ * @packageDocumentation */ +export * from './analytics'; export * from './apis'; export * from './components'; export * from './extensions'; From 9f07394a999922d9b15991806a14d2633d714bd5 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Wed, 8 Nov 2023 14:09:33 +0100 Subject: [PATCH 2/8] feat: use analytics tracker on routing provider Signed-off-by: Camila Belo --- .../src/routing/RouteTracker.test.tsx | 228 ++++++++++++++++++ .../src/routing/RouteTracker.tsx | 141 +++++++++++ .../frontend-app-api/src/wiring/createApp.tsx | 2 + 3 files changed, 371 insertions(+) create mode 100644 packages/frontend-app-api/src/routing/RouteTracker.test.tsx create mode 100644 packages/frontend-app-api/src/routing/RouteTracker.tsx diff --git a/packages/frontend-app-api/src/routing/RouteTracker.test.tsx b/packages/frontend-app-api/src/routing/RouteTracker.test.tsx new file mode 100644 index 0000000000..fa5a4f633a --- /dev/null +++ b/packages/frontend-app-api/src/routing/RouteTracker.test.tsx @@ -0,0 +1,228 @@ +/* + * Copyright 2023 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 { TestApiProvider } from '@backstage/test-utils'; +import React from 'react'; +import { BackstageRouteObject } from './types'; +import { fireEvent, render } from '@testing-library/react'; +import { RouteTracker } from './RouteTracker'; +import { Link, MemoryRouter, Route, Routes } from 'react-router-dom'; +import { createPlugin } from '@backstage/core-plugin-api'; +import { + createRouteRef, + AnalyticsApi, + analyticsApiRef, +} from '@backstage/frontend-plugin-api'; +import { MATCH_ALL_ROUTE } from './extractRouteInfoFromAppNode'; + +describe('RouteTracker', () => { + const routeRef0 = createRouteRef(); + const routeRef1 = createRouteRef(); + const routeRef2 = createRouteRef(); + const plugin0 = createPlugin({ id: 'home' }); + const plugin1 = createPlugin({ id: 'plugin1' }); + const plugin2 = createPlugin({ id: 'plugin2' }); + + const routeObjects: BackstageRouteObject[] = [ + { + path: '', + element:
home page
, + routeRefs: new Set([routeRef0]), + plugins: new Set([plugin0]), + caseSensitive: false, + children: [MATCH_ALL_ROUTE], + }, + { + path: '/path/:p1/:p2', + element: go, + routeRefs: new Set([routeRef1]), + plugins: new Set([plugin1]), + caseSensitive: false, + children: [MATCH_ALL_ROUTE], + }, + { + path: '/path2/:param', + element:
hi there
, + routeRefs: new Set([routeRef2]), + plugins: new Set([plugin2]), + caseSensitive: false, + children: [MATCH_ALL_ROUTE], + }, + ]; + + const mockedAnalytics: jest.Mocked = { + captureEvent: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should capture the navigate event on load', async () => { + render( + + + + + , + ); + + expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({ + action: 'navigate', + attributes: { + p1: 'foo', + p2: 'bar', + }, + context: { + extension: 'App', + pluginId: 'plugin1', + routeRef: undefined, + }, + subject: '/path/foo/bar', + value: undefined, + }); + }); + + it('should capture the navigate event on route change', async () => { + const { getByText } = render( + + + + + + {routeObjects.map(({ path, element }) => ( + + ))} + + + , + ); + + fireEvent.click(getByText('go')); + + expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({ + action: 'navigate', + attributes: { + param: 'hello', + }, + context: { + extension: 'App', + pluginId: 'plugin2', + routeRef: undefined, + }, + subject: '/path2/hello', + value: undefined, + }); + }); + + it('should capture path query and hash', async () => { + render( + + + + + , + ); + + expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({ + action: 'navigate', + attributes: { + p1: 'foo', + p2: 'bar', + }, + context: { + extension: 'App', + pluginId: 'plugin1', + routeRef: undefined, + }, + subject: '/path/foo/bar?q=1#header-1', + value: undefined, + }); + }); + + it('should match the root path and send relevant context', async () => { + render( + + + + + , + ); + + expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({ + action: 'navigate', + attributes: {}, + context: { + extension: 'App', + pluginId: 'home', + routeRef: undefined, + }, + subject: '/', + value: undefined, + }); + }); + + it('should return default context when it would have otherwise matched on the root path', async () => { + render( + + + + + Non-extension} + /> + + + , + ); + + expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({ + action: 'navigate', + attributes: {}, + context: { + extension: 'App', + pluginId: 'root', + routeRef: 'unknown', + }, + subject: '/not-routable-extension', + value: undefined, + }); + }); + + it('should return parent route context on navigating to a sub-route', async () => { + render( + + + + + , + ); + + expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({ + action: 'navigate', + attributes: { + param: 'param-value', + }, + context: { + extension: 'App', + pluginId: 'plugin2', + routeRef: undefined, + }, + subject: '/path2/param-value/sub-route', + value: undefined, + }); + }); +}); diff --git a/packages/frontend-app-api/src/routing/RouteTracker.tsx b/packages/frontend-app-api/src/routing/RouteTracker.tsx new file mode 100644 index 0000000000..a25a12fae2 --- /dev/null +++ b/packages/frontend-app-api/src/routing/RouteTracker.tsx @@ -0,0 +1,141 @@ +/* + * Copyright 2023 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 React, { useEffect } from 'react'; +import { matchRoutes, useLocation } from 'react-router-dom'; +import { RouteRef, BackstagePlugin } from '@backstage/core-plugin-api'; +import { + useAnalytics, + AnalyticsContext, + AnalyticsEventAttributes, +} from '@backstage/core-plugin-api'; +import { BackstageRouteObject } from './types'; + +/** + * Returns an extension context given the current pathname and a list of + * Backstage route objects. + */ +const getExtensionContext = ( + pathname: string, + routes: BackstageRouteObject[], +) => { + try { + // Find matching routes for the given path name. + const matches = matchRoutes(routes, { pathname }); + + // Of the matching routes, get the last (e.g. most specific) instance of + // the BackstageRouteObject that contains a routeRef. Filtering by routeRef + // ensures subRouteRefs are aligned to their parent routes' context. + const routeMatch = matches + ?.filter(match => match?.route.routeRefs?.size > 0) + .pop(); + const routeObject = routeMatch?.route; + + // If there is no route object, then allow inheritance of default context. + if (!routeObject) { + return undefined; + } + + // If the matched route is the root route (no path), and the pathname is + // not the path of the homepage, then inherit from the default context. + if (routeObject.path === '' && pathname !== '/') { + return undefined; + } + + // If there is a single route ref, use it. + let routeRef: RouteRef | undefined; + if (routeObject.routeRefs.size === 1) { + routeRef = routeObject.routeRefs.values().next().value; + } + + // If there is a single plugin, use it. + let plugin: BackstagePlugin | undefined; + if (routeObject.plugins.size === 1) { + plugin = routeObject.plugins.values().next().value; + } + + const params = Object.entries( + routeMatch?.params || {}, + ).reduce((acc, [key, value]) => { + if (value !== undefined && key !== '*') { + acc[key] = value; + } + return acc; + }, {}); + + return { + extension: 'App', + pluginId: plugin?.getId() || 'root', + ...(routeRef ? { routeRef: (routeRef as { id?: string }).id } : {}), + _routeNodeType: routeObject.element as string, + params, + }; + } catch { + return undefined; + } +}; + +/** + * Performs the actual event capture on render. + */ +const TrackNavigation = ({ + pathname, + search, + hash, + attributes, +}: { + pathname: string; + search: string; + hash: string; + attributes?: AnalyticsEventAttributes; +}) => { + const analytics = useAnalytics(); + useEffect(() => { + analytics.captureEvent('navigate', `${pathname}${search}${hash}`, { + attributes, + }); + }, [analytics, pathname, search, hash, attributes]); + + return null; +}; + +/** + * Logs a "navigate" event with appropriate plugin-level analytics context + * attributes each time the user navigates to a page. + */ +export const RouteTracker = ({ + routeObjects, +}: { + routeObjects: BackstageRouteObject[]; +}) => { + const { pathname, search, hash } = useLocation(); + + const { params, ...attributes } = getExtensionContext( + pathname, + routeObjects, + ) || { params: {} }; + + return ( + + + + ); +}; diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index a9400d3ba4..6f5a1592c2 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -97,6 +97,7 @@ import { CoreRouter } from '../extensions/CoreRouter'; import { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiring/createPlugin'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides'; +import { RouteTracker } from '../routing/RouteTracker'; const builtinExtensions = [ Core, @@ -342,6 +343,7 @@ export function createSpecializedApp(options?: { + {rootEl} From 6ee84d8fdcaf884d201c23a11f3df48754107151 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 13 Nov 2023 10:09:56 +0100 Subject: [PATCH 3/8] docs: update api reports Signed-off-by: Camila Belo --- packages/frontend-plugin-api/api-report.md | 55 ++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index c5005d34ac..d4acc4b5b7 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -86,6 +86,51 @@ export { alertApiRef }; export { AlertMessage }; +// @public +export type AnalyticsApi = { + captureEvent(event: AnalyticsEvent): void; +}; + +// @public +export const analyticsApiRef: ApiRef; + +// @public +export const AnalyticsContext: (options: { + attributes: Partial; + children: ReactNode; +}) => React_2.JSX.Element; + +// @public +export type AnalyticsContextValue = CommonAnalyticsContext & { + [param in string]: string | boolean | number | undefined; +}; + +// @public +export type AnalyticsEvent = { + action: string; + subject: string; + value?: number; + attributes?: AnalyticsEventAttributes; + context: AnalyticsContextValue; +}; + +// @public +export type AnalyticsEventAttributes = { + [attribute in string]: string | boolean | number; +}; + +// @public +export type AnalyticsTracker = { + captureEvent: ( + action: string, + subject: string, + options?: { + value?: number; + attributes?: AnalyticsEventAttributes; + }, + ) => void; +}; + export { AnyApiFactory }; export { AnyApiRef }; @@ -233,6 +278,13 @@ export { bitbucketAuthApiRef }; export { bitbucketServerAuthApiRef }; +// @public +export type CommonAnalyticsContext = { + pluginId: string; + routeRef: string; + extension: string; +}; + export { ConfigApi }; export { configApiRef }; @@ -770,6 +822,9 @@ export interface SubRouteRef< export { TypesToApiRefs }; +// @public +export function useAnalytics(): AnalyticsTracker; + export { useApi }; export { useApiHolder }; From f27ee7d937a17e135b8ec864dfa87bf44df2538e Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 13 Nov 2023 09:58:18 +0100 Subject: [PATCH 4/8] docs: add changeset files Signed-off-by: Camila Belo --- .changeset/pretty-ravens-serve.md | 5 +++++ .changeset/smooth-frogs-decide.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/pretty-ravens-serve.md create mode 100644 .changeset/smooth-frogs-decide.md diff --git a/.changeset/pretty-ravens-serve.md b/.changeset/pretty-ravens-serve.md new file mode 100644 index 0000000000..95d1b629fd --- /dev/null +++ b/.changeset/pretty-ravens-serve.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Migrate analytics route tracker component. diff --git a/.changeset/smooth-frogs-decide.md b/.changeset/smooth-frogs-decide.md new file mode 100644 index 0000000000..942a3a80bf --- /dev/null +++ b/.changeset/smooth-frogs-decide.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Migrate analytics api and context files. From 5c794cd5835c6c2be565134d0a744743dc95637a Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 27 Nov 2023 14:39:17 +0100 Subject: [PATCH 5/8] refactor: add app not to route objs Signed-off-by: Camila Belo --- .../src/extensions/CoreRouter.tsx | 11 ++++- .../src/routing/RouteTracker.test.tsx | 34 +++++++++----- .../src/routing/RouteTracker.tsx | 22 ++------- .../routing/extractRouteInfoFromAppNode.ts | 1 + .../frontend-app-api/src/routing/types.ts | 2 + .../src/wiring/InternalAppContext.ts | 2 + .../frontend-app-api/src/wiring/createApp.tsx | 6 +-- .../src/analytics/AnalyticsContext.test.tsx | 19 ++++---- .../src/analytics/AnalyticsContext.tsx | 3 +- .../src/analytics/Tracker.test.ts | 47 +++++-------------- .../src/analytics/Tracker.ts | 13 ++--- .../src/analytics/types.ts | 6 +-- .../src/analytics/useAnalytics.test.tsx | 3 +- 13 files changed, 73 insertions(+), 96 deletions(-) diff --git a/packages/frontend-app-api/src/extensions/CoreRouter.tsx b/packages/frontend-app-api/src/extensions/CoreRouter.tsx index f128a246e6..510af04b51 100644 --- a/packages/frontend-app-api/src/extensions/CoreRouter.tsx +++ b/packages/frontend-app-api/src/extensions/CoreRouter.tsx @@ -33,6 +33,7 @@ import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations import { BrowserRouter } from 'react-router-dom'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { signInPageComponentDataRef } from '../../../frontend-plugin-api/src/extensions/createSignInPageExtension'; +import { RouteTracker } from '../routing/RouteTracker'; export const CoreRouter = createExtension({ id: 'core.router', @@ -132,7 +133,7 @@ export function AppRouter(props: AppRouterProps) { if (!internalAppContext) { throw new Error('AppRouter must be rendered within the AppProvider'); } - const { appIdentityProxy } = internalAppContext; + const { routeObjects, appIdentityProxy } = internalAppContext; // If the app hasn't configured a sign-in page, we just continue as guest. if (!SignInPageComponent) { @@ -159,11 +160,17 @@ export function AppRouter(props: AppRouterProps) { { signOutTargetUrl: basePath || '/' }, ); - return {children}; + return ( + + + {children} + + ); } return ( + { plugins: new Set([plugin0]), caseSensitive: false, children: [MATCH_ALL_ROUTE], + appNode: { + spec: { extension: { id: 'home.page.index' }, source: { id: 'home' } }, + } as AppNode, }, { path: '/path/:p1/:p2', @@ -51,6 +55,12 @@ describe('RouteTracker', () => { plugins: new Set([plugin1]), caseSensitive: false, children: [MATCH_ALL_ROUTE], + appNode: { + spec: { + extension: { id: 'plugin1.page.index' }, + source: { id: 'plugin1' }, + }, + } as AppNode, }, { path: '/path2/:param', @@ -59,6 +69,12 @@ describe('RouteTracker', () => { plugins: new Set([plugin2]), caseSensitive: false, children: [MATCH_ALL_ROUTE], + appNode: { + spec: { + extension: { id: 'plugin2.page.index' }, + source: { id: 'plugin2' }, + }, + } as AppNode, }, ]; @@ -86,9 +102,8 @@ describe('RouteTracker', () => { p2: 'bar', }, context: { - extension: 'App', + extensionId: 'plugin1.page.index', pluginId: 'plugin1', - routeRef: undefined, }, subject: '/path/foo/bar', value: undefined, @@ -118,9 +133,8 @@ describe('RouteTracker', () => { param: 'hello', }, context: { - extension: 'App', + extensionId: 'plugin2.page.index', pluginId: 'plugin2', - routeRef: undefined, }, subject: '/path2/hello', value: undefined, @@ -143,9 +157,8 @@ describe('RouteTracker', () => { p2: 'bar', }, context: { - extension: 'App', + extensionId: 'plugin1.page.index', pluginId: 'plugin1', - routeRef: undefined, }, subject: '/path/foo/bar?q=1#header-1', value: undefined, @@ -165,16 +178,15 @@ describe('RouteTracker', () => { action: 'navigate', attributes: {}, context: { - extension: 'App', + extensionId: 'home.page.index', pluginId: 'home', - routeRef: undefined, }, subject: '/', value: undefined, }); }); - it('should return default context when it would have otherwise matched on the root path', async () => { + it.skip('should return default context when it would have otherwise matched on the root path', async () => { render( @@ -195,7 +207,6 @@ describe('RouteTracker', () => { context: { extension: 'App', pluginId: 'root', - routeRef: 'unknown', }, subject: '/not-routable-extension', value: undefined, @@ -217,9 +228,8 @@ describe('RouteTracker', () => { param: 'param-value', }, context: { - extension: 'App', + extensionId: 'plugin2.page.index', pluginId: 'plugin2', - routeRef: undefined, }, subject: '/path2/param-value/sub-route', value: undefined, diff --git a/packages/frontend-app-api/src/routing/RouteTracker.tsx b/packages/frontend-app-api/src/routing/RouteTracker.tsx index a25a12fae2..7bceb05cf9 100644 --- a/packages/frontend-app-api/src/routing/RouteTracker.tsx +++ b/packages/frontend-app-api/src/routing/RouteTracker.tsx @@ -16,7 +16,6 @@ import React, { useEffect } from 'react'; import { matchRoutes, useLocation } from 'react-router-dom'; -import { RouteRef, BackstagePlugin } from '@backstage/core-plugin-api'; import { useAnalytics, AnalyticsContext, @@ -55,18 +54,6 @@ const getExtensionContext = ( return undefined; } - // If there is a single route ref, use it. - let routeRef: RouteRef | undefined; - if (routeObject.routeRefs.size === 1) { - routeRef = routeObject.routeRefs.values().next().value; - } - - // If there is a single plugin, use it. - let plugin: BackstagePlugin | undefined; - if (routeObject.plugins.size === 1) { - plugin = routeObject.plugins.values().next().value; - } - const params = Object.entries( routeMatch?.params || {}, ).reduce((acc, [key, value]) => { @@ -76,12 +63,13 @@ const getExtensionContext = ( return acc; }, {}); + const plugin = routeObject.appNode?.spec.source; + const extension = routeObject.appNode?.spec.extension; + return { - extension: 'App', - pluginId: plugin?.getId() || 'root', - ...(routeRef ? { routeRef: (routeRef as { id?: string }).id } : {}), - _routeNodeType: routeObject.element as string, params, + pluginId: plugin?.id || 'root', + extensionId: extension?.id || 'App', }; } catch { return undefined; diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts index 2bfd09a3e8..17afbeb4c9 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.ts @@ -84,6 +84,7 @@ export function extractRouteInfoFromAppNode(node: AppNode): { caseSensitive: false, children: [MATCH_ALL_ROUTE], plugins: new Set(), + appNode: current, }; parentChildren.push(currentObj); diff --git a/packages/frontend-app-api/src/routing/types.ts b/packages/frontend-app-api/src/routing/types.ts index 5f1ed9be84..05ea73dff8 100644 --- a/packages/frontend-app-api/src/routing/types.ts +++ b/packages/frontend-app-api/src/routing/types.ts @@ -15,6 +15,7 @@ */ import { + AppNode, ExternalRouteRef, RouteRef, SubRouteRef, @@ -35,4 +36,5 @@ export interface BackstageRouteObject { path: string; routeRefs: Set; plugins: Set; + appNode?: AppNode; } diff --git a/packages/frontend-app-api/src/wiring/InternalAppContext.ts b/packages/frontend-app-api/src/wiring/InternalAppContext.ts index 805c46a067..81264bd81b 100644 --- a/packages/frontend-app-api/src/wiring/InternalAppContext.ts +++ b/packages/frontend-app-api/src/wiring/InternalAppContext.ts @@ -17,10 +17,12 @@ import { createContext } from 'react'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy'; +import { BackstageRouteObject } from '../routing/types'; export const InternalAppContext = createContext< | undefined | { appIdentityProxy: AppIdentityProxy; + routeObjects: BackstageRouteObject[]; } >(undefined); diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 6f5a1592c2..a1412f1c3c 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -97,7 +97,6 @@ import { CoreRouter } from '../extensions/CoreRouter'; import { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiring/createPlugin'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides'; -import { RouteTracker } from '../routing/RouteTracker'; const builtinExtensions = [ Core, @@ -342,8 +341,9 @@ export function createSpecializedApp(options?: { - - + {rootEl} diff --git a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx index c29ec5b3ca..8e78bdc8c4 100644 --- a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx +++ b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.test.tsx @@ -23,9 +23,8 @@ const AnalyticsSpy = () => { const context = useAnalyticsContext(); return ( <> -
{context.routeRef}
{context.pluginId}
-
{context.extension}
+
{context.extensionId}
{context.custom}
); @@ -36,9 +35,8 @@ describe('AnalyticsContext', () => { it('returns default values', () => { const { result } = renderHook(() => useAnalyticsContext()); expect(result.current).toEqual({ - extension: 'App', + extensionId: 'App', pluginId: 'root', - routeRef: 'unknown', }); }); }); @@ -51,9 +49,8 @@ describe('AnalyticsContext', () => { , ); - expect(result.getByTestId('extension')).toHaveTextContent('App'); + expect(result.getByTestId('extension-id')).toHaveTextContent('App'); expect(result.getByTestId('plugin-id')).toHaveTextContent('root'); - expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); }); it('uses provided analytics context', () => { @@ -63,23 +60,23 @@ describe('AnalyticsContext', () => { , ); - expect(result.getByTestId('extension')).toHaveTextContent('App'); + expect(result.getByTestId('extension-id')).toHaveTextContent('App'); expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); - expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); }); it('uses nested analytics context', () => { const result = render( - + , ); - expect(result.getByTestId('extension')).toHaveTextContent('AnalyticsSpy'); + expect(result.getByTestId('extension-id')).toHaveTextContent( + 'AnalyticsSpy', + ); expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); - expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); }); }); }); diff --git a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx index 105be8e2b5..10ef475803 100644 --- a/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx +++ b/packages/frontend-plugin-api/src/analytics/AnalyticsContext.tsx @@ -37,9 +37,8 @@ export const useAnalyticsContext = (): AnalyticsContextValue => { // Provide a default value if no value exists. if (theContext === undefined) { return { - routeRef: 'unknown', pluginId: 'root', - extension: 'App', + extensionId: 'App', }; } diff --git a/packages/frontend-plugin-api/src/analytics/Tracker.test.ts b/packages/frontend-plugin-api/src/analytics/Tracker.test.ts index e0e3a87f6d..f1670cf9c4 100644 --- a/packages/frontend-plugin-api/src/analytics/Tracker.test.ts +++ b/packages/frontend-plugin-api/src/analytics/Tracker.test.ts @@ -19,9 +19,8 @@ import { Tracker, routableExtensionRenderedEvent } from './Tracker'; describe('Tracker', () => { const defaultContext = { - routeRef: 'unknown', pluginId: 'root', - extension: 'App', + extensionId: 'App', }; const globalEvents = getOrCreateGlobalSingleton( 'core-plugin-api:analytics-tracker-events', @@ -56,9 +55,8 @@ describe('Tracker', () => { it('captures simple event with set context', () => { trackerUnderTest.setContext({ - routeRef: 'catalog:entity', pluginId: 'catalog', - extension: 'App', + extensionId: 'App', }); trackerUnderTest.captureEvent('click', 'test 2', { value: 2, @@ -72,9 +70,8 @@ describe('Tracker', () => { value: 2, attributes: { to: '/page-2' }, context: { - routeRef: 'catalog:entity', pluginId: 'catalog', - extension: 'App', + extensionId: 'App', }, }), ); @@ -83,10 +80,8 @@ describe('Tracker', () => { describe('accurate navigate events', () => { it('never captures _routeNodeType context key on navigate event', () => { trackerUnderTest.setContext({ - routeRef: 'catalog:entity', pluginId: 'catalog', - extension: 'App', - _routeNodeType: 'mounted', + extensionId: 'App', }); trackerUnderTest.captureEvent('navigate', '/page-3'); @@ -99,7 +94,6 @@ describe('Tracker', () => { it('never immediately captures navigate event with _routeNodeType "gathered"', () => { trackerUnderTest.setContext({ ...defaultContext, - _routeNodeType: 'gathered', }); trackerUnderTest.captureEvent('navigate', '/page-4'); @@ -116,15 +110,13 @@ describe('Tracker', () => { // User navigates to a gathered mountpoint with multiple plugins trackerUnderTest.setContext({ ...defaultContext, - _routeNodeType: 'gathered', }); trackerUnderTest.captureEvent('navigate', '/page-5'); // A routable extension is rendered with specific plugin context trackerUnderTest.setContext({ - routeRef: 'some:ref', pluginId: 'some-plugin', - extension: 'App', + extensionId: 'App', }); trackerUnderTest.captureEvent(routableExtensionRenderedEvent, ''); @@ -138,9 +130,8 @@ describe('Tracker', () => { action: 'navigate', subject: '/page-5', context: { - routeRef: 'some:ref', pluginId: 'some-plugin', - extension: 'App', + extensionId: 'App', }, }), ); @@ -150,9 +141,8 @@ describe('Tracker', () => { action: 'click', subject: 'test 5', context: { - routeRef: 'some:ref', pluginId: 'some-plugin', - extension: 'App', + extensionId: 'App', }, }), ); @@ -162,22 +152,19 @@ describe('Tracker', () => { // User navigates to a gathered mountpoint with multiple plugins trackerUnderTest.setContext({ ...defaultContext, - _routeNodeType: 'gathered', }); trackerUnderTest.captureEvent('navigate', '/page-6'); // A routable extension is rendered with specific plugin context trackerUnderTest.setContext({ - routeRef: 'another:ref', pluginId: 'another-plugin', - extension: 'App', + extensionId: 'App', }); trackerUnderTest.captureEvent(routableExtensionRenderedEvent, ''); // User navigates to another gathered mountpoint with multiple plugins trackerUnderTest.setContext({ ...defaultContext, - _routeNodeType: 'gathered', }); trackerUnderTest.captureEvent('navigate', '/page-6-2'); @@ -188,9 +175,8 @@ describe('Tracker', () => { action: 'navigate', subject: '/page-6', context: { - routeRef: 'another:ref', pluginId: 'another-plugin', - extension: 'App', + extensionId: 'App', }, }), ); @@ -200,7 +186,6 @@ describe('Tracker', () => { // User navigates to a gathered mountpoint with multiple plugins trackerUnderTest.setContext({ ...defaultContext, - _routeNodeType: 'gathered', }); trackerUnderTest.captureEvent('navigate', '/page-7'); @@ -230,16 +215,14 @@ describe('Tracker', () => { // User navigates to a gathered mountpoint with multiple plugins trackerUnderTest.setContext({ ...defaultContext, - _routeNodeType: 'gathered', }); trackerUnderTest.captureEvent('navigate', '/page-8'); // A routable extension is rendered with specific plugin context and // captured via a separate tracker instance. const anotherTracker = new Tracker(analyticsApiSpy, { - routeRef: 'the:ref', pluginId: 'the-plugin', - extension: 'App', + extensionId: 'App', }); anotherTracker.captureEvent(routableExtensionRenderedEvent, ''); @@ -254,9 +237,8 @@ describe('Tracker', () => { action: 'navigate', subject: '/page-8', context: { - routeRef: 'the:ref', pluginId: 'the-plugin', - extension: 'App', + extensionId: 'App', }, }), ); @@ -273,15 +255,13 @@ describe('Tracker', () => { // User navigates to a gathered mountpoint with multiple plugins trackerUnderTest.setContext({ ...defaultContext, - _routeNodeType: 'gathered', }); trackerUnderTest.captureEvent('navigate', '/page-9'); // A routable extension is rendered with specific plugin context trackerUnderTest.setContext({ - routeRef: 'very:ref', pluginId: 'very-plugin', - extension: 'ShouldBeReplaced', + extensionId: 'ShouldBeReplaced', }); trackerUnderTest.captureEvent(routableExtensionRenderedEvent, ''); @@ -295,9 +275,8 @@ describe('Tracker', () => { action: 'navigate', subject: '/page-9', context: { - routeRef: 'very:ref', pluginId: 'very-plugin', - extension: 'App', + extensionId: 'App', }, }), ); diff --git a/packages/frontend-plugin-api/src/analytics/Tracker.ts b/packages/frontend-plugin-api/src/analytics/Tracker.ts index 130410e2a2..dd0d770354 100644 --- a/packages/frontend-plugin-api/src/analytics/Tracker.ts +++ b/packages/frontend-plugin-api/src/analytics/Tracker.ts @@ -68,9 +68,8 @@ export class Tracker implements AnalyticsTracker { constructor( private readonly analyticsApi: AnalyticsApi, private context: AnalyticsContextValue = { - routeRef: 'unknown', pluginId: 'root', - extension: 'App', + extensionId: 'App', }, ) { // Only register a single beforeunload event across all trackers. @@ -110,7 +109,7 @@ export class Tracker implements AnalyticsTracker { }: { value?: number; attributes?: AnalyticsEventAttributes } = {}, ) { // Never pass internal "_routeNodeType" context value. - const { _routeNodeType, ...context } = this.context; + const context = this.context; // Never fire the special "_routable-extension-rendered" internal event. if (action === routableExtensionRenderedEvent) { @@ -120,7 +119,7 @@ export class Tracker implements AnalyticsTracker { globalEvents.mostRecentRoutableExtensionRender = { context: { ...context, - extension: 'App', + extensionId: 'App', }, }; } @@ -148,11 +147,7 @@ export class Tracker implements AnalyticsTracker { // Never directly fire a navigation event on a gathered route with default // contextual details. - if ( - action === 'navigate' && - _routeNodeType === 'gathered' && - context.pluginId === 'root' - ) { + if (action === 'navigate' && context.pluginId === 'root') { // Instead, set it on the global store. globalEvents.mostRecentGatheredNavigation = { action, diff --git a/packages/frontend-plugin-api/src/analytics/types.ts b/packages/frontend-plugin-api/src/analytics/types.ts index ed5dcd588d..21347e2fc7 100644 --- a/packages/frontend-plugin-api/src/analytics/types.ts +++ b/packages/frontend-plugin-api/src/analytics/types.ts @@ -26,14 +26,12 @@ export type CommonAnalyticsContext = { pluginId: string; /** - * The ID of the routeRef that was active when the event was captured. + * The nearest known parent extension where the event was captured. */ - routeRef: string; - /** * The nearest known parent extension where the event was captured. */ - extension: string; + extensionId: string; }; /** diff --git a/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx b/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx index e83826991a..630ce0331a 100644 --- a/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx +++ b/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx @@ -58,9 +58,8 @@ describe('useAnalytics', () => { some: 'value', }, context: { - extension: 'App', + extensionId: 'App', pluginId: 'root', - routeRef: 'unknown', }, }); }); From 5ef37a9c39b6395606a63b810b21227cf1619917 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 27 Nov 2023 15:26:42 +0100 Subject: [PATCH 6/8] test: fix route tracker skipped test Co-authored-by: Patrik Oldsberg Signed-off-by: Camila Belo --- .../src/routing/RouteTracker.test.tsx | 32 +++++-- .../src/routing/RouteTracker.tsx | 2 +- .../extractRouteInfoFromAppNode.test.ts | 85 +++++++++++++++++-- 3 files changed, 103 insertions(+), 16 deletions(-) diff --git a/packages/frontend-app-api/src/routing/RouteTracker.test.tsx b/packages/frontend-app-api/src/routing/RouteTracker.test.tsx index 0696b533df..0e66d0e50d 100644 --- a/packages/frontend-app-api/src/routing/RouteTracker.test.tsx +++ b/packages/frontend-app-api/src/routing/RouteTracker.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { TestApiProvider } from '@backstage/test-utils'; -import React from 'react'; +import React, { useEffect } from 'react'; import { BackstageRouteObject } from './types'; import { fireEvent, render } from '@testing-library/react'; import { RouteTracker } from './RouteTracker'; @@ -25,6 +25,7 @@ import { AnalyticsApi, analyticsApiRef, AppNode, + useAnalytics, } from '@backstage/frontend-plugin-api'; import { MATCH_ALL_ROUTE } from './extractRouteInfoFromAppNode'; @@ -186,31 +187,46 @@ describe('RouteTracker', () => { }); }); - it.skip('should return default context when it would have otherwise matched on the root path', async () => { + it('should return default context when it would have otherwise matched on the root path', async () => { + const Dummy = () => { + const analytics = useAnalytics(); + useEffect(() => { + analytics.captureEvent('click', 'test', {}); + }, [analytics]); + return
dummy
; + }; + render( - Non-extension} - /> + } /> , ); - expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({ + expect(mockedAnalytics.captureEvent).toHaveBeenNthCalledWith(1, { action: 'navigate', attributes: {}, context: { - extension: 'App', + extensionId: 'App', pluginId: 'root', }, subject: '/not-routable-extension', value: undefined, }); + expect(mockedAnalytics.captureEvent).toHaveBeenNthCalledWith(2, { + action: 'click', + attributes: undefined, + context: { + extensionId: 'App', + pluginId: 'root', + }, + subject: 'test', + value: undefined, + }); }); it('should return parent route context on navigating to a sub-route', async () => { diff --git a/packages/frontend-app-api/src/routing/RouteTracker.tsx b/packages/frontend-app-api/src/routing/RouteTracker.tsx index 7bceb05cf9..8cddca5c68 100644 --- a/packages/frontend-app-api/src/routing/RouteTracker.tsx +++ b/packages/frontend-app-api/src/routing/RouteTracker.tsx @@ -20,7 +20,7 @@ import { useAnalytics, AnalyticsContext, AnalyticsEventAttributes, -} from '@backstage/core-plugin-api'; +} from '@backstage/frontend-plugin-api'; import { BackstageRouteObject } from './types'; /** diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts index 112e1435ae..c94a06a442 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts @@ -19,6 +19,7 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { extractRouteInfoFromAppNode } from './extractRouteInfoFromAppNode'; import { AnyRouteRefParams, + AppNode, Extension, RouteRef, coreExtensionData, @@ -99,8 +100,10 @@ function routeObj( children: any[] = [], type: 'mounted' | 'gathered' = 'mounted', backstagePlugin?: BackstagePlugin, + appNode?: AppNode, ) { return { + appNode, path: path, caseSensitive: false, element: type, @@ -171,7 +174,14 @@ describe('discovery', () => { [ref5, ref1], ]); expect(info.routeObjects).toEqual([ - routeObj('nothing', []), + routeObj( + 'nothing', + [], + undefined, + undefined, + undefined, + expect.any(Object), + ), routeObj( 'foo', [ref1], @@ -179,16 +189,41 @@ describe('discovery', () => { routeObj( 'bar/:id', [ref2], - [routeObj('baz', [ref3], undefined, undefined, expect.any(Object))], + [ + routeObj( + 'baz', + [ref3], + undefined, + undefined, + expect.any(Object), + expect.any(Object), + ), + ], undefined, expect.any(Object), + expect.any(Object), + ), + routeObj( + 'blop', + [ref5], + undefined, + undefined, + expect.any(Object), + expect.any(Object), ), - routeObj('blop', [ref5], undefined, undefined, expect.any(Object)), ], undefined, expect.any(Object), + expect.any(Object), + ), + routeObj( + 'divsoup', + [ref4], + undefined, + undefined, + expect.any(Object), + expect.any(Object), ), - routeObj('divsoup', [ref4], undefined, undefined, expect.any(Object)), ]); }); @@ -349,13 +384,30 @@ describe('discovery', () => { [ref5, ref3], ]); expect(info.routeObjects).toEqual([ - routeObj('foo', [ref1, ref2], [], 'mounted', expect.any(Object)), + routeObj( + 'foo', + [ref1, ref2], + [], + 'mounted', + expect.any(Object), + expect.any(Object), + ), routeObj( 'bar', [ref3], - [routeObj('', [ref4, ref5], [], 'mounted', expect.any(Object))], + [ + routeObj( + '', + [ref4, ref5], + [], + 'mounted', + expect.any(Object), + expect.any(Object), + ), + ], 'mounted', expect.any(Object), + expect.any(Object), ), ]); }); @@ -429,18 +481,22 @@ describe('discovery', () => { undefined, undefined, expect.any(Object), + expect.any(Object), ), ], undefined, expect.any(Object), + expect.any(Object), ), ], 'mounted', expect.any(Object), + expect.any(Object), ), ], undefined, expect.any(Object), + expect.any(Object), ), ]); }); @@ -499,7 +555,14 @@ describe('discovery', () => { 'r', [], [ - routeObj('x', [ref1], [], 'mounted', expect.any(Object)), + routeObj( + 'x', + [ref1], + [], + 'mounted', + expect.any(Object), + expect.any(Object), + ), routeObj( 'y', [], @@ -514,6 +577,7 @@ describe('discovery', () => { undefined, 'mounted', expect.any(Object), + expect.any(Object), ), routeObj( 'b', @@ -521,15 +585,22 @@ describe('discovery', () => { undefined, 'mounted', expect.any(Object), + expect.any(Object), ), ], 'mounted', expect.any(Object), + expect.any(Object), ), ], 'mounted', + undefined, + expect.any(Object), ), ], + undefined, + undefined, + expect.any(Object), ), ]); }); From 705fa5b5496891bc86f72f600fa7e922fa579d41 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 27 Nov 2023 16:01:41 +0100 Subject: [PATCH 7/8] docs: update frontend-plugin-api report Signed-off-by: Camila Belo --- packages/frontend-plugin-api/api-report.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index d4acc4b5b7..c1c920559e 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -281,8 +281,7 @@ export { bitbucketServerAuthApiRef }; // @public export type CommonAnalyticsContext = { pluginId: string; - routeRef: string; - extension: string; + extensionId: string; }; export { ConfigApi }; From 1def17fd023171740e036d0759bb6a422997a860 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Mon, 27 Nov 2023 16:11:43 +0100 Subject: [PATCH 8/8] refactor: use apis provider on the analytics context test Signed-off-by: Camila Belo --- .../src/analytics/useAnalytics.test.tsx | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx b/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx index 630ce0331a..beb16b1fbb 100644 --- a/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx +++ b/packages/frontend-plugin-api/src/analytics/useAnalytics.test.tsx @@ -16,36 +16,31 @@ import { renderHook } from '@testing-library/react'; import { useAnalytics } from './useAnalytics'; -import { useApi } from '@backstage/core-plugin-api'; - -jest.mock('@backstage/core-plugin-api', () => ({ - ...jest.requireActual('@backstage/core-plugin-api'), - useApi: jest.fn(), -})); - -const mocked = (f: Function) => f as jest.Mock; +import { analyticsApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; +import React from 'react'; describe('useAnalytics', () => { it('returns tracker with no implementation defined', () => { - // Simulate useApi() throwing an error. - mocked(useApi).mockImplementation(() => { - throw new Error(); - }); - - // Result should still have a captureEvent method. - const { result } = renderHook(() => useAnalytics()); + // useApi throws an error because the Analytics API is not implemented + // But the result should still have a captureEvent method. + const { result } = renderHook(() => useAnalytics(), {}); expect(result.current.captureEvent).toBeDefined(); }); it('returns tracker from defined analytics api', () => { const captureEvent = jest.fn(); - // Simulate useApi returning a valid tracker. - mocked(useApi).mockReturnValue({ captureEvent }); - // Calling the captureEvent method of the underlying implementation should // pass along the given event as well as the default context. - const { result } = renderHook(() => useAnalytics()); + const { result } = renderHook(() => useAnalytics(), { + wrapper: ({ children }) => ( + // Simulate useApi returning a valid tracker. + + {children} + + ), + }); result.current.captureEvent('an action', 'a subject', { value: 42, attributes: { some: 'value' },