feat: copy analytics context files

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2023-11-08 09:58:30 +01:00
parent 1aa1c78ad2
commit 6e32443ebd
11 changed files with 1002 additions and 0 deletions
@@ -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 (
<>
<div data-testid="route-ref">{context.routeRef}</div>
<div data-testid="plugin-id">{context.pluginId}</div>
<div data-testid="extension">{context.extension}</div>
<div data-testid="custom">{context.custom}</div>
</>
);
};
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(
<AnalyticsContext attributes={{}}>
<AnalyticsSpy />
</AnalyticsContext>,
);
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(
<AnalyticsContext attributes={{ pluginId: 'custom' }}>
<AnalyticsSpy />
</AnalyticsContext>,
);
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(
<AnalyticsContext attributes={{ pluginId: 'custom' }}>
<AnalyticsContext attributes={{ extension: 'AnalyticsSpy' }}>
<AnalyticsSpy />
</AnalyticsContext>
</AnalyticsContext>,
);
expect(result.getByTestId('extension')).toHaveTextContent('AnalyticsSpy');
expect(result.getByTestId('plugin-id')).toHaveTextContent('custom');
expect(result.getByTestId('route-ref')).toHaveTextContent('unknown');
});
});
});
@@ -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<AnalyticsContextValue>;
children: ReactNode;
}) => {
const { attributes, children } = options;
const parentValues = useAnalyticsContext();
const combinedValue = {
...parentValues,
...attributes,
};
const versionedCombinedValue = createVersionedValueMap({ 1: combinedValue });
return (
<AnalyticsReactContext.Provider value={versionedCombinedValue}>
{children}
</AnalyticsReactContext.Provider>
);
};
/**
* 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<TProps extends {}>(
Component: React.ComponentType<TProps>,
values: AnalyticsContextValue,
) {
const ComponentWithAnalyticsContext = (props: TProps) => {
return (
<AnalyticsContext attributes={values}>
<Component {...props} />
</AnalyticsContext>
);
};
ComponentWithAnalyticsContext.displayName = `WithAnalyticsContext(${
Component.displayName || Component.name || 'Component'
})`;
return ComponentWithAnalyticsContext;
}
@@ -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<any>(
'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',
},
}),
);
});
});
});
@@ -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<TempGlobalEvents>(
'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);
}
}
}
@@ -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';
@@ -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;
};
@@ -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',
},
});
});
});
@@ -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<Tracker | null>(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;
}
@@ -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<AnalyticsApi> = createApiRef({
id: 'core.analytics',
});
@@ -42,3 +42,4 @@ export * from './FetchApi';
export * from './IdentityApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
export * from './AnalyticsApi';
@@ -20,6 +20,7 @@
* @packageDocumentation
*/
export * from './analytics';
export * from './apis';
export * from './components';
export * from './extensions';