diff --git a/.changeset/analytics-old-skipping-record.md b/.changeset/analytics-old-skipping-record.md new file mode 100644 index 0000000000..a1ee11af84 --- /dev/null +++ b/.changeset/analytics-old-skipping-record.md @@ -0,0 +1,23 @@ +--- +'@backstage/core-plugin-api': minor +--- + +Introducing the Analytics API: a lightweight way for plugins to instrument key +events that could help inform a Backstage Integrator how their instance of +Backstage is being used. The API consists of the following: + +- `useAnalytics()`, a hook to be used inside plugin components which retrieves + an Analytics Tracker. +- `tracker.captureEvent()`, a method on the tracker used to instrument key + events. The method expects a verb (the action performed), a noun (a unique + identifier of the object the action is being taken on), and optionally a + numeric metric/value. +- ``, a way to declaratively attach additional information + to any/all events captured in the underlying React tree. There is also a + `withAnalyticsDomain()` HOC utility. +- The `tracker.captureEvent()` method also accepts a `context` object for + optionally providing additional run-time information about an event. + +By default, captured events are not sent anywhere. In order to collect and +redirect events to an analytics system, the `analyticsApi` will need to be +implemented and instantiated by an App Integrator. diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index f92dbd44f0..7c833d2b66 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -34,6 +34,76 @@ export type AlertMessage = { severity?: 'success' | 'info' | 'warning' | 'error'; }; +// Warning: (ae-missing-release-tag) "AnalyticsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnalyticsApi = { + getDecoratedTracker({ + domain, + }: { + domain: AnalyticsDomainValue; + }): AnalyticsTracker; +}; + +// Warning: (ae-missing-release-tag) "analyticsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const analyticsApiRef: ApiRef; + +// Warning: (ae-missing-release-tag) "AnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export const AnalyticsDomain: ({ + attributes, + children, +}: { + attributes: AnalyticsDomainValue; + children: ReactNode; +}) => JSX.Element; + +// Warning: (ae-missing-release-tag) "AnalyticsDomainValue" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnalyticsDomainValue = Partial< + RoutableAnalyticsDomain & ComponentAnalyticsDomain & AnyAnalyticsDomain +>; + +// Warning: (ae-missing-release-tag) "AnalyticsEvent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnalyticsEvent = { + verb: string; + noun: string; + value?: number; + context?: AnalyticsEventContext; +}; + +// Warning: (ae-missing-release-tag) "AnalyticsEventContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnalyticsEventContext = { + [attribute in string]: string | boolean | number; +}; + +// Warning: (ae-missing-release-tag) "AnalyticsTracker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnalyticsTracker = { + captureEvent: ( + verb: string, + noun: string, + value?: number, + context?: AnalyticsEventContext, + ) => void; +}; + +// Warning: (ae-missing-release-tag) "AnyAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AnyAnalyticsDomain = { + [param in string]: string | boolean | number | undefined; +}; + // Warning: (ae-missing-release-tag) "AnyApiFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -234,6 +304,14 @@ export type BootErrorPageProps = { error: Error; }; +// Warning: (ae-missing-release-tag) "ComponentAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type ComponentAnalyticsDomain = { + pluginId: string; + componentName: string; +}; + // Warning: (ae-missing-release-tag) "ConfigApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -273,7 +351,7 @@ export function createApiRef(config: ApiRefConfig): ApiRef; // @public (undocumented) export function createComponentExtension< T extends (props: any) => JSX.Element | null, ->(options: { component: ComponentLoader }): Extension; +>(options: { component: ComponentLoader; name?: string }): Extension; // Warning: (ae-forgotten-export) The symbol "OptionalParams" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "createExternalRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -309,6 +387,7 @@ export function createReactExtension< >(options: { component: ComponentLoader; data?: Record; + name?: string; }): Extension; // Warning: (ae-missing-release-tag) "createRoutableExtension" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -316,7 +395,11 @@ export function createReactExtension< // @public (undocumented) export function createRoutableExtension< T extends (props: any) => JSX.Element | null, ->(options: { component: () => Promise; mountPoint: RouteRef }): Extension; +>(options: { + component: () => Promise; + mountPoint: RouteRef; + name?: string; +}): Extension; // Warning: (ae-missing-release-tag) "createRouteRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -361,6 +444,13 @@ export type DiscoveryApi = { // @public (undocumented) export const discoveryApiRef: ApiRef; +// Warning: (ae-missing-release-tag) "DomainDecoratedAnalyticsEvent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type DomainDecoratedAnalyticsEvent = AnalyticsEvent & { + domain: AnalyticsDomainValue; +}; + // Warning: (ae-missing-release-tag) "ElementCollection" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -704,6 +794,15 @@ export type ProfileInfoApi = { getProfile(options?: AuthRequestOptions): Promise; }; +// Warning: (ae-missing-release-tag) "RoutableAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type RoutableAnalyticsDomain = { + pluginId: string; + routeRef: string; + componentName: string; +}; + // Warning: (ae-missing-release-tag) "RouteOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -832,6 +931,11 @@ export type TypesToApiRefs = { [key in keyof T]: ApiRef; }; +// Warning: (ae-missing-release-tag) "useAnalytics" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function useAnalytics(): AnalyticsTracker; + // Warning: (ae-missing-release-tag) "useApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -882,6 +986,17 @@ export function useRouteRefParams( _routeRef: RouteRef | SubRouteRef, ): Params; +// Warning: (ae-missing-release-tag) "withAnalyticsDomain" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export function withAnalyticsDomain

( + Component: React_2.ComponentType

, + domain: AnyAnalyticsDomain, +): { + (props: P): JSX.Element; + displayName: string; +}; + // Warning: (ae-missing-release-tag) "withApis" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -900,7 +1015,7 @@ export function withApis(apis: TypesToApiRefs):

( // src/apis/definitions/auth.d.ts:96:68 - (tsdoc-undefined-tag) The TSDoc tag "@AuthRequestOptions" is not defined in this configuration // src/apis/definitions/auth.d.ts:110:16 - (tsdoc-undefined-tag) The TSDoc tag "@IdentityApi" is not defined in this configuration // src/apis/definitions/auth.d.ts:113:68 - (tsdoc-undefined-tag) The TSDoc tag "@AuthRequestOptions" is not defined in this configuration -// src/extensions/extensions.d.ts:14:5 - (ae-forgotten-export) The symbol "ComponentLoader" needs to be exported by the entry point index.d.ts +// src/extensions/extensions.d.ts:15:5 - (ae-forgotten-export) The symbol "ComponentLoader" needs to be exported by the entry point index.d.ts // src/routing/RouteRef.d.ts:35:5 - (ae-forgotten-export) The symbol "OldIconComponent" needs to be exported by the entry point index.d.ts // src/routing/types.d.ts:30:5 - (ae-forgotten-export) The symbol "ParamKeys" needs to be exported by the entry point index.d.ts ``` diff --git a/packages/core-plugin-api/src/analytics/AnalyticsDomain.test.tsx b/packages/core-plugin-api/src/analytics/AnalyticsDomain.test.tsx new file mode 100644 index 0000000000..e83ae478e4 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/AnalyticsDomain.test.tsx @@ -0,0 +1,100 @@ +/* + * 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-hooks'; +import { + AnalyticsDomain, + useAnalyticsDomain, + withAnalyticsDomain, +} from './AnalyticsDomain'; + +const DomainSpy = () => { + const domain = useAnalyticsDomain(); + return ( + <> +

{domain.routeRef}
+
{domain.pluginId}
+
{domain.componentName}
+
{domain.custom}
+ + ); +}; + +describe('AnalyticsDomain', () => { + describe('useAnalyticsDomain', () => { + it('returns default values', () => { + const { result } = renderHook(() => useAnalyticsDomain()); + expect(result.current).toEqual({ + componentName: 'App', + pluginId: 'root', + routeRef: 'unknown', + }); + }); + }); + + describe('AnalyticsDomain', () => { + it('uses default analytics domain', () => { + const result = render( + + + , + ); + + expect(result.getByTestId('component-name')).toHaveTextContent('App'); + expect(result.getByTestId('plugin-id')).toHaveTextContent('root'); + expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); + }); + + it('uses provided analytics domain', () => { + const result = render( + + + , + ); + + expect(result.getByTestId('component-name')).toHaveTextContent('App'); + expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); + expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); + }); + + it('uses nested analytics domain', () => { + const result = render( + + + + + , + ); + + expect(result.getByTestId('component-name')).toHaveTextContent( + 'DomainSpy', + ); + expect(result.getByTestId('plugin-id')).toHaveTextContent('custom'); + expect(result.getByTestId('route-ref')).toHaveTextContent('unknown'); + }); + }); + + describe('withAnalyticsDomain', () => { + it('wraps component with analytics domain', () => { + const DomainSpyHOC = withAnalyticsDomain(DomainSpy, { custom: 'attr' }); + const result = render(); + expect(result.getByTestId('custom')).toHaveTextContent('attr'); + expect(DomainSpyHOC.displayName).toBe('WithAnalyticsDomain(DomainSpy)'); + }); + }); +}); diff --git a/packages/core-plugin-api/src/analytics/AnalyticsDomain.tsx b/packages/core-plugin-api/src/analytics/AnalyticsDomain.tsx new file mode 100644 index 0000000000..be1b90290c --- /dev/null +++ b/packages/core-plugin-api/src/analytics/AnalyticsDomain.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, { createContext, ReactNode, useContext } from 'react'; +import { AnalyticsDomainValue, AnyAnalyticsDomain } from './types'; + +export const AnalyticsDomainContext = createContext({ + routeRef: 'unknown', + pluginId: 'root', + componentName: 'App', +}); + +/** + * A "private" (to this package) hook that enables context inheritance and a + * way to read Analytics Domain values at event capture-time. + * @private + */ +export const useAnalyticsDomain = () => { + return useContext(AnalyticsDomainContext); +}; + +/** + * Provides components in the child react tree an Analytics Domain, ensuring + * all analytics events captured within the domain have relevant contextual + * attributes. + * + * Analytics domains are additive, meaning the domain ultimately emitted with + * an event is the combination of all domains in the parent tree. + */ +export const AnalyticsDomain = ({ + attributes, + children, +}: { + attributes: AnalyticsDomainValue; + children: ReactNode; +}) => { + const parentValues = useAnalyticsDomain(); + const combinedValue = { + ...parentValues, + ...attributes, + }; + + return ( + + {children} + + ); +}; + +/** + * Returns an HOC wrapping the provided component in an Analytics Domain with + * the given values. + * + * @param Component - Component to be wrapped with analytics domain attributes. + * @param domain - Analytics domain key/value pairs. + */ +export function withAnalyticsDomain

( + Component: React.ComponentType

, + domain: AnyAnalyticsDomain, +) { + const ComponentWithAnalyticsDomain = (props: P) => { + return ( + + + + ); + }; + ComponentWithAnalyticsDomain.displayName = `WithAnalyticsDomain(${ + Component.displayName || Component.name || 'Component' + })`; + return ComponentWithAnalyticsDomain; +} diff --git a/packages/core-plugin-api/src/analytics/index.ts b/packages/core-plugin-api/src/analytics/index.ts new file mode 100644 index 0000000000..308069f9a8 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { AnalyticsDomain, withAnalyticsDomain } from './AnalyticsDomain'; +export type { + AnalyticsDomainValue, + AnyAnalyticsDomain, + ComponentAnalyticsDomain, + RoutableAnalyticsDomain, +} from './types'; +export { useAnalytics } from './useAnalytics'; diff --git a/packages/core-plugin-api/src/analytics/types.ts b/packages/core-plugin-api/src/analytics/types.ts new file mode 100644 index 0000000000..acbda41da9 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/types.ts @@ -0,0 +1,64 @@ +/* + * 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. + */ + +/** + * Analytics domain covering routable extensions. + */ +export type RoutableAnalyticsDomain = { + /** + * The plugin that exposed the route. + */ + pluginId: string; + + /** + * The ID of the route ref associated with the route. + */ + routeRef: string; + + /** + * The name of the component used to render the route. + */ + componentName: string; +}; + +/** + * Analytics domain covering component extensions. + */ +export type ComponentAnalyticsDomain = { + /** + * The plugin that exposed the component. + */ + pluginId: string; + + /** + * The name of the component. + */ + componentName: string; +}; + +/** + * Allow arbitrary scalar values as domain attributes too. + */ +export type AnyAnalyticsDomain = { + [param in string]: string | boolean | number | undefined; +}; + +/** + * Common analytics domain attributes. + */ +export type AnalyticsDomainValue = Partial< + RoutableAnalyticsDomain & ComponentAnalyticsDomain & AnyAnalyticsDomain +>; diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx new file mode 100644 index 0000000000..33e71aa2e4 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/useAnalytics.test.tsx @@ -0,0 +1,61 @@ +/* + * 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-hooks'; +import { useAnalytics } from './useAnalytics'; +import { useApi } from '../apis'; + +jest.mock('../apis'); + +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 expectedFunction = 'capture function'; + const getDecoratedTracker = jest.fn().mockReturnValue({ + captureEvent: expectedFunction, + }); + + // Simulate useApi returning a valid tracker. + mocked(useApi).mockReturnValue({ getDecoratedTracker }); + + // The getDecoratedTracker method of the underlying implementation should + // have been called with the domain provided. + const { result } = renderHook(() => useAnalytics()); + expect(getDecoratedTracker).toHaveBeenCalledWith({ + domain: { + componentName: 'App', + pluginId: 'root', + routeRef: 'unknown', + }, + }); + + // And the returned tracker's captureEvent should have come from the API + // implementation. + expect(result.current.captureEvent).toBe(expectedFunction); + }); +}); diff --git a/packages/core-plugin-api/src/analytics/useAnalytics.tsx b/packages/core-plugin-api/src/analytics/useAnalytics.tsx new file mode 100644 index 0000000000..e3dc851321 --- /dev/null +++ b/packages/core-plugin-api/src/analytics/useAnalytics.tsx @@ -0,0 +1,44 @@ +/* + * 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 { useAnalyticsDomain } from './AnalyticsDomain'; +import { + analyticsApiRef, + AnalyticsTracker, +} from '../apis/definitions/AnalyticsApi'; +import { useApi } from '../apis'; + +function useTracker(): AnalyticsTracker { + const analyticsApi = useApi(analyticsApiRef); + const domain = useAnalyticsDomain(); + return analyticsApi.getDecoratedTracker({ domain }); +} + +/** + * Get a pre-configured analytics tracker. + */ +export function useAnalytics(): AnalyticsTracker { + // Return a no-op tracker if no implementation for the Analytics API is + // available. Having no default Analytics API implementation enables simple + // provider installation via plugin instantiation. + try { + return useTracker(); + } catch { + return { + captureEvent: () => {}, + }; + } +} diff --git a/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts new file mode 100644 index 0000000000..0e5f87b038 --- /dev/null +++ b/packages/core-plugin-api/src/apis/definitions/AnalyticsApi.ts @@ -0,0 +1,123 @@ +/* + * 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 '../system'; +import { AnalyticsDomainValue } 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. + * + * Note that attributes about the Backstage user or about the plugin tracking + * the event are inferred and captured separately on the Analytics Domain and + * do not need to be passed on the event itself. + */ +export type AnalyticsEvent = { + /** + * A string that identifies the event being tracked by the type of action the + * event represents. Examples include: + * + * - view + * - click + * - filter + * - search + * - hover + * - scroll + */ + verb: string; + + /** + * A string that uniquely identifies the object that the verb or 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 + */ + noun: 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 + */ + value?: number; + + /** + * Optional context with any additional dimensions or metrics that could be + * forwarded on to analytics systems. + */ + context?: AnalyticsEventContext; +}; + +/** + * An analytics event combined with domain attributes. + */ +export type DomainDecoratedAnalyticsEvent = AnalyticsEvent & { + /** + * Domain metadata relating to where the event was captured and by whom. This + * could include information about the route, plugin, or component in which + * an event was captured. + */ + domain: AnalyticsDomainValue; +}; + +/** + * A structure allowing other arbitrary metadata to be provided by analytics + * event emitters. + */ +export type AnalyticsEventContext = { + [attribute in string]: string | boolean | number; +}; + +/** + * Represents a tracker with methods that can be called to track events in a + * configured analytics service. + */ +export type AnalyticsTracker = { + captureEvent: ( + verb: string, + noun: string, + value?: number, + context?: AnalyticsEventContext, + ) => void; +}; + +/** + * The Analytics API is used to track user behavior in a Backstage instance. + * + * 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. + */ +export type AnalyticsApi = { + /** + * Retrieves a tracker decorated with a given analytics domain. + */ + getDecoratedTracker({ + domain, + }: { + domain: AnalyticsDomainValue; + }): AnalyticsTracker; +}; + +export const analyticsApiRef: ApiRef = createApiRef({ + id: 'core.analytics', +}); diff --git a/packages/core-plugin-api/src/apis/definitions/index.ts b/packages/core-plugin-api/src/apis/definitions/index.ts index d4350ddbf6..b7666bcb54 100644 --- a/packages/core-plugin-api/src/apis/definitions/index.ts +++ b/packages/core-plugin-api/src/apis/definitions/index.ts @@ -23,6 +23,7 @@ export * from './auth'; export * from './AlertApi'; +export * from './AnalyticsApi'; export * from './AppThemeApi'; export * from './ConfigApi'; export * from './DiscoveryApi'; diff --git a/packages/core-plugin-api/src/extensions/extensions.test.tsx b/packages/core-plugin-api/src/extensions/extensions.test.tsx index e8b9d9c534..422b346a2c 100644 --- a/packages/core-plugin-api/src/extensions/extensions.test.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.test.tsx @@ -17,6 +17,7 @@ import { withLogCollector } from '@backstage/test-utils-core'; import { render, screen } from '@testing-library/react'; import React from 'react'; +import { useAnalyticsDomain } from '../analytics/AnalyticsDomain'; import { useApp, ErrorBoundaryFallbackProps } from '../app'; import { createPlugin } from '../plugin'; import { createRouteRef } from '../routing'; @@ -107,4 +108,32 @@ describe('extensions', () => { screen.getByText('Error in my-plugin'); expect(errors[0]).toMatch('Test error'); }); + + it('should wrap extended component with analytics domain', async () => { + const DomainSpyExtension = plugin.provide( + createReactExtension({ + name: 'DomainSpy', + component: { + sync: () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const domain = useAnalyticsDomain(); + return ( + <> +

{domain.pluginId}
+
{domain.routeRef}
+
{domain.componentName}
+ + ); + }, + }, + data: { 'core.mountpoint': { id: 'some-ref' } }, + }), + ); + + const result = render(); + + expect(result.getByTestId('plugin-id')).toHaveTextContent('my-plugin'); + expect(result.getByTestId('route-ref')).toHaveTextContent('some-ref'); + expect(result.getByTestId('component-name')).toHaveTextContent('DomainSpy'); + }); }); diff --git a/packages/core-plugin-api/src/extensions/extensions.tsx b/packages/core-plugin-api/src/extensions/extensions.tsx index db39d2088c..444ca6783c 100644 --- a/packages/core-plugin-api/src/extensions/extensions.tsx +++ b/packages/core-plugin-api/src/extensions/extensions.tsx @@ -15,6 +15,7 @@ */ import React, { lazy, Suspense } from 'react'; +import { AnalyticsDomain } from '../analytics/AnalyticsDomain'; import { useApp } from '../app'; import { RouteRef, useRouteRef } from '../routing'; import { attachComponentData } from './componentData'; @@ -94,7 +95,7 @@ export function createRoutableExtension< // ComponentType inserts children as an optional prop whether the inner component accepts it or not, // making it impossible to make the usage of children type safe. export function createComponentExtension< - T extends (props: any) => JSX.Element | null + T extends (props: any) => JSX.Element | null, >(options: { component: ComponentLoader; name?: string }): Extension { const { component, name } = options; return createReactExtension({ component, name }); @@ -136,7 +137,20 @@ export function createReactExtension< return ( }> - + + + ); diff --git a/packages/core-plugin-api/src/index.ts b/packages/core-plugin-api/src/index.ts index f0c1069652..782416c4d0 100644 --- a/packages/core-plugin-api/src/index.ts +++ b/packages/core-plugin-api/src/index.ts @@ -20,6 +20,7 @@ * @packageDocumentation */ +export * from './analytics'; export * from './apis'; export * from './app'; export * from './extensions';