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';