,
): 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';