Merge pull request #22482 from backstage/camilaibs/fix-analytics-context
[DI] Add analytics api backward compatibility
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': minor
|
||||
---
|
||||
|
||||
**BREAKING**: Replace default plugin extension and plugin ids to be `app` instead of `root`.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/plugin-analytics-module-newrelic-browser': minor
|
||||
'@backstage/plugin-analytics-module-ga4': minor
|
||||
'@backstage/plugin-analytics-module-ga': minor
|
||||
'@backstage/core-compat-api': minor
|
||||
---
|
||||
|
||||
Add support to the new analytics api.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-app-api': patch
|
||||
---
|
||||
|
||||
Wrap the root element with the analytics context to ensure it always exists for all extensions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-plugin-api': patch
|
||||
---
|
||||
|
||||
Throw a more specific exception `NotImplementedError` when an API implementation cannot be found.
|
||||
@@ -3,6 +3,10 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { AnalyticsApi } from '@backstage/core-plugin-api';
|
||||
import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api';
|
||||
import { AnalyticsEvent } from '@backstage/core-plugin-api';
|
||||
import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api';
|
||||
import { AnyRouteRefParams } from '@backstage/core-plugin-api';
|
||||
import { ExternalRouteRef } from '@backstage/core-plugin-api';
|
||||
import { ExternalRouteRef as ExternalRouteRef_2 } from '@backstage/frontend-plugin-api';
|
||||
@@ -51,6 +55,20 @@ export function convertLegacyRouteRefs<
|
||||
[KName in keyof TRefs]: ToNewRouteRef<TRefs[KName]>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export class MultipleAnalyticsApi implements AnalyticsApi, AnalyticsApi_2 {
|
||||
captureEvent(event: AnalyticsEvent | AnalyticsEvent_2): void;
|
||||
static fromApis(
|
||||
actualApis: (AnalyticsApi | AnalyticsApi_2)[],
|
||||
): MultipleAnalyticsApi;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class NoOpAnalyticsApi implements AnalyticsApi, AnalyticsApi_2 {
|
||||
// (undocumented)
|
||||
captureEvent(_event: AnalyticsEvent | AnalyticsEvent_2): void;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type ToNewRouteRef<T extends RouteRef | SubRouteRef | ExternalRouteRef> =
|
||||
T extends RouteRef<infer IParams>
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2024 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 { MultipleAnalyticsApi } from './MultipleAnalyticsApi';
|
||||
|
||||
describe('MultipleAnalyticsApi', () => {
|
||||
const analyticsApiOne = { captureEvent: jest.fn() };
|
||||
const analyticsApiTwo = { captureEvent: jest.fn() };
|
||||
const multipleApis = MultipleAnalyticsApi.fromApis([
|
||||
analyticsApiOne,
|
||||
analyticsApiTwo,
|
||||
]);
|
||||
|
||||
const event = {
|
||||
action: 'navivate',
|
||||
subject: '/path',
|
||||
context: {
|
||||
extension: 'App',
|
||||
pluginId: 'plugin',
|
||||
routeRef: 'unknown',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('forwards events to all apis', () => {
|
||||
// When an event is captured
|
||||
multipleApis.captureEvent(event);
|
||||
|
||||
// Then both underlying APIs should have received the event
|
||||
expect(analyticsApiOne.captureEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analyticsApiOne.captureEvent).toHaveBeenCalledWith(event);
|
||||
expect(analyticsApiTwo.captureEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analyticsApiTwo.captureEvent).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('forwards events to all apis even if one throws an error', () => {
|
||||
// Given one underlying API that throws on capture
|
||||
analyticsApiOne.captureEvent.mockImplementation(() => {
|
||||
throw new Error('!!!');
|
||||
});
|
||||
|
||||
// When an event is captured
|
||||
multipleApis.captureEvent(event);
|
||||
|
||||
// Then the other underlying API should have still received the event
|
||||
expect(analyticsApiTwo.captureEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analyticsApiTwo.captureEvent).toHaveBeenCalledWith(event);
|
||||
});
|
||||
});
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2024 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 { AnalyticsApi, AnalyticsEvent } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
AnalyticsApi as NewAnalyicsApi,
|
||||
AnalyticsEvent as NewAnalyicsEvent,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
/**
|
||||
* An implementation of the AnalyticsApi that can be used to forward analytics
|
||||
* events to multiple concrete implementations.
|
||||
*
|
||||
* @public
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```jsx
|
||||
* createApiFactory({
|
||||
* api: analyticsApiRef,
|
||||
* deps: { configApi: configApiRef, identityApi: identityApiRef, storageApi: storageApiRef },
|
||||
* factory: ({ configApi, identityApi, storageApi }) =>
|
||||
* MultipleAnalyticsApi.fromApis([
|
||||
* VendorAnalyticsApi.fromConfig(configApi, { identityApi }),
|
||||
* CustomAnalyticsApi.fromConfig(configApi, { identityApi, storageApi }),
|
||||
* ]),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class MultipleAnalyticsApi implements AnalyticsApi, NewAnalyicsApi {
|
||||
private constructor(
|
||||
private readonly actualApis: (AnalyticsApi | NewAnalyicsApi)[],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create an AnalyticsApi implementation from an array of concrete
|
||||
* implementations.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```jsx
|
||||
* MultipleAnalyticsApi.fromApis([
|
||||
* SomeAnalyticsApi.fromConfig(configApi),
|
||||
* new CustomAnalyticsApi(),
|
||||
* ]);
|
||||
* ```
|
||||
*/
|
||||
static fromApis(actualApis: (AnalyticsApi | NewAnalyicsApi)[]) {
|
||||
return new MultipleAnalyticsApi(actualApis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward the event to all configured analytics API implementations.
|
||||
*/
|
||||
captureEvent(event: AnalyticsEvent | NewAnalyicsEvent): void {
|
||||
this.actualApis.forEach(analyticsApi => {
|
||||
try {
|
||||
analyticsApi.captureEvent(event as AnalyticsEvent & NewAnalyicsEvent);
|
||||
} catch {
|
||||
/* ignored */
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright 2024 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 { AnalyticsApi, AnalyticsEvent } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
AnalyticsApi as NewAnalyicsApi,
|
||||
AnalyticsEvent as NewAnalyicsEvent,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
/**
|
||||
* Base implementation for the AnalyticsApi that does nothing.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class NoOpAnalyticsApi implements AnalyticsApi, NewAnalyicsApi {
|
||||
captureEvent(_event: AnalyticsEvent | NewAnalyicsEvent): void {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2024 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 { MultipleAnalyticsApi } from './MultipleAnalyticsApi';
|
||||
export { NoOpAnalyticsApi } from './NoOpAnalyticsApi';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2024 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 * from './AnalyticsApi';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2024 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 * from './implementations';
|
||||
@@ -15,6 +15,8 @@
|
||||
*/
|
||||
export * from './compatWrapper';
|
||||
|
||||
export * from './apis';
|
||||
|
||||
export { convertLegacyApp } from './convertLegacyApp';
|
||||
export {
|
||||
convertLegacyRouteRef,
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { ApiRef, ApiHolder, TypesToApiRefs } from './types';
|
||||
import { useVersionedContext } from '@backstage/version-bridge';
|
||||
import { NotImplementedError } from '@backstage/errors';
|
||||
|
||||
/**
|
||||
* React hook for retrieving {@link ApiHolder}, an API catalog.
|
||||
@@ -26,12 +27,12 @@ import { useVersionedContext } from '@backstage/version-bridge';
|
||||
export function useApiHolder(): ApiHolder {
|
||||
const versionedHolder = useVersionedContext<{ 1: ApiHolder }>('api-context');
|
||||
if (!versionedHolder) {
|
||||
throw new Error('API context is not available');
|
||||
throw new NotImplementedError('API context is not available');
|
||||
}
|
||||
|
||||
const apiHolder = versionedHolder.atVersion(1);
|
||||
if (!apiHolder) {
|
||||
throw new Error('ApiContext v1 not available');
|
||||
throw new NotImplementedError('ApiContext v1 not available');
|
||||
}
|
||||
return apiHolder;
|
||||
}
|
||||
@@ -47,7 +48,7 @@ export function useApi<T>(apiRef: ApiRef<T>): T {
|
||||
|
||||
const api = apiHolder.get(apiRef);
|
||||
if (!api) {
|
||||
throw new Error(`No implementation available for ${apiRef}`);
|
||||
throw new NotImplementedError(`No implementation available for ${apiRef}`);
|
||||
}
|
||||
return api;
|
||||
}
|
||||
@@ -73,7 +74,9 @@ export function withApis<T extends {}>(apis: TypesToApiRefs<T>) {
|
||||
|
||||
const api = apiHolder.get(ref);
|
||||
if (!api) {
|
||||
throw new Error(`No implementation available for ${ref}`);
|
||||
throw new NotImplementedError(
|
||||
`No implementation available for ${ref}`,
|
||||
);
|
||||
}
|
||||
impls[key] = api;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ExtensionBoundary,
|
||||
coreExtensionData,
|
||||
createApiExtension,
|
||||
createComponentExtension,
|
||||
@@ -50,9 +52,13 @@ export const App = createExtension({
|
||||
output: {
|
||||
root: coreExtensionData.reactElement,
|
||||
},
|
||||
factory({ inputs }) {
|
||||
factory({ node, inputs }) {
|
||||
return {
|
||||
root: inputs.root.output.element,
|
||||
root: (
|
||||
<ExtensionBoundary node={node}>
|
||||
{inputs.root.output.element}
|
||||
</ExtensionBoundary>
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -211,8 +211,8 @@ describe('RouteTracker', () => {
|
||||
action: 'navigate',
|
||||
attributes: {},
|
||||
context: {
|
||||
extensionId: 'App',
|
||||
pluginId: 'root',
|
||||
extensionId: 'app',
|
||||
pluginId: 'app',
|
||||
},
|
||||
subject: '/not-routable-extension',
|
||||
value: undefined,
|
||||
@@ -221,8 +221,8 @@ describe('RouteTracker', () => {
|
||||
action: 'click',
|
||||
attributes: undefined,
|
||||
context: {
|
||||
extensionId: 'App',
|
||||
pluginId: 'root',
|
||||
extensionId: 'app',
|
||||
pluginId: 'app',
|
||||
},
|
||||
subject: 'test',
|
||||
value: undefined,
|
||||
|
||||
@@ -35,8 +35,8 @@ describe('AnalyticsContext', () => {
|
||||
it('returns default values', () => {
|
||||
const { result } = renderHook(() => useAnalyticsContext());
|
||||
expect(result.current).toEqual({
|
||||
extensionId: 'App',
|
||||
pluginId: 'root',
|
||||
extensionId: 'app',
|
||||
pluginId: 'app',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -49,8 +49,8 @@ describe('AnalyticsContext', () => {
|
||||
</AnalyticsContext>,
|
||||
);
|
||||
|
||||
expect(result.getByTestId('extension-id')).toHaveTextContent('App');
|
||||
expect(result.getByTestId('plugin-id')).toHaveTextContent('root');
|
||||
expect(result.getByTestId('extension-id')).toHaveTextContent('app');
|
||||
expect(result.getByTestId('plugin-id')).toHaveTextContent('app');
|
||||
});
|
||||
|
||||
it('uses provided analytics context', () => {
|
||||
@@ -60,7 +60,7 @@ describe('AnalyticsContext', () => {
|
||||
</AnalyticsContext>,
|
||||
);
|
||||
|
||||
expect(result.getByTestId('extension-id')).toHaveTextContent('App');
|
||||
expect(result.getByTestId('extension-id')).toHaveTextContent('app');
|
||||
expect(result.getByTestId('plugin-id')).toHaveTextContent('custom');
|
||||
});
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ export const useAnalyticsContext = (): AnalyticsContextValue => {
|
||||
// Provide a default value if no value exists.
|
||||
if (theContext === undefined) {
|
||||
return {
|
||||
pluginId: 'root',
|
||||
extensionId: 'App',
|
||||
pluginId: 'app',
|
||||
extensionId: 'app',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -25,9 +25,6 @@ export type CommonAnalyticsContext = {
|
||||
*/
|
||||
pluginId: string;
|
||||
|
||||
/**
|
||||
* The nearest known parent extension where the event was captured.
|
||||
*/
|
||||
/**
|
||||
* The nearest known parent extension where the event was captured.
|
||||
*/
|
||||
|
||||
@@ -53,8 +53,8 @@ describe('useAnalytics', () => {
|
||||
some: 'value',
|
||||
},
|
||||
context: {
|
||||
extensionId: 'App',
|
||||
pluginId: 'root',
|
||||
extensionId: 'app',
|
||||
pluginId: 'app',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,8 +23,11 @@ import { Tracker } from './Tracker';
|
||||
function useAnalyticsApi(): AnalyticsApi {
|
||||
try {
|
||||
return useApi(analyticsApiRef);
|
||||
} catch {
|
||||
return { captureEvent: () => {} };
|
||||
} catch (error) {
|
||||
if (error.name === 'NotImplementedError') {
|
||||
return { captureEvent: () => {} };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
|
||||
// Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight
|
||||
const attributes = {
|
||||
extensionId: node.spec.id,
|
||||
pluginId: node.spec.source?.id,
|
||||
pluginId: node.spec.source?.id ?? 'app',
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
```ts
|
||||
import { AnalyticsApi } from '@backstage/core-plugin-api';
|
||||
import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api';
|
||||
import { AnalyticsEvent } from '@backstage/core-plugin-api';
|
||||
import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
@@ -13,8 +15,8 @@ import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
export const analyticsModuleGA: BackstagePlugin<{}, {}>;
|
||||
|
||||
// @public
|
||||
export class GoogleAnalytics implements AnalyticsApi {
|
||||
captureEvent(event: AnalyticsEvent): void;
|
||||
export class GoogleAnalytics implements AnalyticsApi, AnalyticsApi_2 {
|
||||
captureEvent(event: AnalyticsEvent | AnalyticsEvent_2): void;
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: {
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/core-components": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"react-ga": "^3.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
+129
@@ -508,4 +508,133 @@ describe('GoogleAnalytics', () => {
|
||||
expect(lastData.queueTime).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('api backward compatibility', () => {
|
||||
it('continue working with legacy App category', () => {
|
||||
const api = GoogleAnalytics.fromConfig(basicValidConfig);
|
||||
|
||||
expect(api.captureEvent).toBeDefined();
|
||||
|
||||
api.captureEvent({
|
||||
action: 'navigate',
|
||||
subject: '/',
|
||||
context,
|
||||
});
|
||||
|
||||
let [command, data] = ReactGA.testModeAPI.calls[1];
|
||||
expect(command).toBe('send');
|
||||
expect(data).toMatchObject({
|
||||
hitType: 'pageview',
|
||||
page: '/',
|
||||
});
|
||||
|
||||
api.captureEvent({
|
||||
action: 'click',
|
||||
subject: 'on something',
|
||||
value: 42,
|
||||
context,
|
||||
});
|
||||
|
||||
[command, data] = ReactGA.testModeAPI.calls[2];
|
||||
expect(command).toBe('send');
|
||||
expect(data).toMatchObject({
|
||||
hitType: 'event',
|
||||
// expect to use the legacy default category
|
||||
eventCategory: 'App',
|
||||
eventAction: 'click',
|
||||
eventLabel: 'on something',
|
||||
eventValue: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('use lowercase app as the new default category', () => {
|
||||
const api = GoogleAnalytics.fromConfig(basicValidConfig);
|
||||
|
||||
expect(api.captureEvent).toBeDefined();
|
||||
|
||||
api.captureEvent({
|
||||
action: 'navigate',
|
||||
subject: '/',
|
||||
context: {
|
||||
...context,
|
||||
extensionId: '',
|
||||
extension: '',
|
||||
},
|
||||
});
|
||||
|
||||
let [command, data] = ReactGA.testModeAPI.calls[1];
|
||||
expect(command).toBe('send');
|
||||
expect(data).toMatchObject({
|
||||
hitType: 'pageview',
|
||||
page: '/',
|
||||
});
|
||||
|
||||
api.captureEvent({
|
||||
action: 'click',
|
||||
subject: 'on something',
|
||||
value: 42,
|
||||
context: {
|
||||
...context,
|
||||
extensionId: '',
|
||||
extension: '',
|
||||
},
|
||||
});
|
||||
|
||||
[command, data] = ReactGA.testModeAPI.calls[2];
|
||||
expect(command).toBe('send');
|
||||
expect(data).toMatchObject({
|
||||
hitType: 'event',
|
||||
// expect to use the new default category
|
||||
eventCategory: 'App',
|
||||
eventAction: 'click',
|
||||
eventLabel: 'on something',
|
||||
eventValue: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('prioritize new context extension id over old extension property', () => {
|
||||
const api = GoogleAnalytics.fromConfig(basicValidConfig);
|
||||
|
||||
expect(api.captureEvent).toBeDefined();
|
||||
|
||||
api.captureEvent({
|
||||
action: 'navigate',
|
||||
subject: '/',
|
||||
context: {
|
||||
...context,
|
||||
extensionId: 'app',
|
||||
extension: '',
|
||||
},
|
||||
});
|
||||
|
||||
let [command, data] = ReactGA.testModeAPI.calls[1];
|
||||
expect(command).toBe('send');
|
||||
expect(data).toMatchObject({
|
||||
hitType: 'pageview',
|
||||
page: '/',
|
||||
});
|
||||
|
||||
api.captureEvent({
|
||||
action: 'click',
|
||||
subject: 'on something',
|
||||
value: 42,
|
||||
context: {
|
||||
...context,
|
||||
extensionId: 'page:index',
|
||||
extension: '',
|
||||
},
|
||||
});
|
||||
|
||||
[command, data] = ReactGA.testModeAPI.calls[2];
|
||||
expect(command).toBe('send');
|
||||
expect(data).toMatchObject({
|
||||
hitType: 'event',
|
||||
// expect use the new context extension id
|
||||
eventCategory: 'page:index',
|
||||
eventAction: 'click',
|
||||
eventLabel: 'on something',
|
||||
eventValue: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+17
-5
@@ -22,6 +22,11 @@ import {
|
||||
AnalyticsEventAttributes,
|
||||
IdentityApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import {
|
||||
AnalyticsApi as NewAnalyticsApi,
|
||||
AnalyticsEvent as NewAnalyticsEvent,
|
||||
AnalyticsContextValue as NewAnalyticsContextValue,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DeferredCapture } from '../../../util';
|
||||
import {
|
||||
@@ -40,7 +45,7 @@ type CustomDimensionOrMetricConfig = {
|
||||
* Google Analytics API provider for the Backstage Analytics API.
|
||||
* @public
|
||||
*/
|
||||
export class GoogleAnalytics implements AnalyticsApi {
|
||||
export class GoogleAnalytics implements AnalyticsApi, NewAnalyticsApi {
|
||||
private readonly cdmConfig: CustomDimensionOrMetricConfig[];
|
||||
private customUserIdTransform?: (userEntityRef: string) => Promise<string>;
|
||||
private readonly capture: DeferredCapture;
|
||||
@@ -157,11 +162,18 @@ export class GoogleAnalytics implements AnalyticsApi {
|
||||
* pageview and the rest as custom events. All custom dimensions/metrics are
|
||||
* applied as they should be (set on pageview, merged object on events).
|
||||
*/
|
||||
captureEvent(event: AnalyticsEvent) {
|
||||
captureEvent(event: AnalyticsEvent | NewAnalyticsEvent) {
|
||||
const { context, action, subject, value, attributes } = event;
|
||||
const customMetadata = this.getCustomDimensionMetrics(context, attributes);
|
||||
|
||||
if (action === 'navigate' && context.extension === 'App') {
|
||||
const extensionId = context.extensionId || context.extension;
|
||||
const category = extensionId ? String(extensionId) : 'App';
|
||||
|
||||
// The legacy default extension was 'App' and the new one is 'app'
|
||||
if (
|
||||
action === 'navigate' &&
|
||||
category.toLocaleLowerCase('en-US').startsWith('app')
|
||||
) {
|
||||
this.capture.pageview(subject, customMetadata);
|
||||
return;
|
||||
}
|
||||
@@ -184,7 +196,7 @@ export class GoogleAnalytics implements AnalyticsApi {
|
||||
}
|
||||
|
||||
this.capture.event({
|
||||
category: context.extension || 'App',
|
||||
category,
|
||||
action,
|
||||
label: subject,
|
||||
value,
|
||||
@@ -197,7 +209,7 @@ export class GoogleAnalytics implements AnalyticsApi {
|
||||
* Event Attributes, e.g. { dimension1: "some value", metric8: 42 }
|
||||
*/
|
||||
private getCustomDimensionMetrics(
|
||||
context: AnalyticsContextValue,
|
||||
context: AnalyticsContextValue | NewAnalyticsContextValue,
|
||||
attributes: AnalyticsEventAttributes = {},
|
||||
) {
|
||||
const customDimensionsMetrics: { [x: string]: string | number | boolean } =
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
|
||||
```ts
|
||||
import { AnalyticsApi } from '@backstage/core-plugin-api';
|
||||
import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api';
|
||||
import { AnalyticsEvent } from '@backstage/core-plugin-api';
|
||||
import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
|
||||
// @public
|
||||
export class GoogleAnalytics4 implements AnalyticsApi {
|
||||
captureEvent(event: AnalyticsEvent): void;
|
||||
export class GoogleAnalytics4 implements AnalyticsApi, AnalyticsApi_2 {
|
||||
captureEvent(event: AnalyticsEvent | AnalyticsEvent_2): void;
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options?: {
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/core-components": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"react-ga4": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
+56
@@ -492,4 +492,60 @@ describe('GoogleAnalytics4', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('api backward compatibility', () => {
|
||||
it('continue working with legacy App category', () => {
|
||||
const api = GoogleAnalytics4.fromConfig(basicValidConfig);
|
||||
|
||||
expect(api.captureEvent).toBeDefined();
|
||||
|
||||
api.captureEvent({
|
||||
action: 'navigate',
|
||||
subject: '/',
|
||||
context,
|
||||
});
|
||||
|
||||
expect(fnEvent).toHaveBeenCalledWith('page_view', {
|
||||
action: 'page_view',
|
||||
label: '/',
|
||||
category: 'App',
|
||||
});
|
||||
});
|
||||
|
||||
it('use lowercase app as the new default category', () => {
|
||||
const api = GoogleAnalytics4.fromConfig(basicValidConfig);
|
||||
|
||||
expect(api.captureEvent).toBeDefined();
|
||||
|
||||
api.captureEvent({
|
||||
action: 'navigate',
|
||||
subject: '/',
|
||||
context: { ...context, extensionId: '', extension: '' },
|
||||
});
|
||||
|
||||
expect(fnEvent).toHaveBeenCalledWith('page_view', {
|
||||
action: 'page_view',
|
||||
label: '/',
|
||||
category: 'App',
|
||||
});
|
||||
});
|
||||
|
||||
it('prioritize new context extension id over old extension property', () => {
|
||||
const api = GoogleAnalytics4.fromConfig(basicValidConfig);
|
||||
|
||||
expect(api.captureEvent).toBeDefined();
|
||||
|
||||
api.captureEvent({
|
||||
action: 'navigate',
|
||||
subject: '/',
|
||||
context: { ...context, extensionId: 'app', extension: '' },
|
||||
});
|
||||
|
||||
expect(fnEvent).toHaveBeenCalledWith('page_view', {
|
||||
action: 'page_view',
|
||||
label: '/',
|
||||
category: 'app',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+19
-6
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import ReactGA from 'react-ga4';
|
||||
import {
|
||||
AnalyticsApi,
|
||||
@@ -21,6 +22,11 @@ import {
|
||||
AnalyticsEvent,
|
||||
IdentityApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import {
|
||||
AnalyticsApi as NewAnalyticsApi,
|
||||
AnalyticsEvent as NewAnalyticsEvent,
|
||||
AnalyticsContextValue as NewAnalyticsContextValue,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DeferredCapture } from '../../../util/DeferredCapture';
|
||||
|
||||
@@ -28,7 +34,7 @@ import { DeferredCapture } from '../../../util/DeferredCapture';
|
||||
* Google Analytics API provider for the Backstage Analytics API.
|
||||
* @public
|
||||
*/
|
||||
export class GoogleAnalytics4 implements AnalyticsApi {
|
||||
export class GoogleAnalytics4 implements AnalyticsApi, NewAnalyticsApi {
|
||||
private readonly customUserIdTransform?: (
|
||||
userEntityRef: string,
|
||||
) => Promise<string>;
|
||||
@@ -157,17 +163,24 @@ export class GoogleAnalytics4 implements AnalyticsApi {
|
||||
* applied as they should be (set on pageview, merged object on events).
|
||||
* @param event - AnalyticsEvent type captured
|
||||
*/
|
||||
captureEvent(event: AnalyticsEvent) {
|
||||
captureEvent(event: AnalyticsEvent | NewAnalyticsEvent) {
|
||||
const { context, action, subject, value, attributes } = event;
|
||||
const customEventData = this.setEventParameters(context, attributes);
|
||||
if (this.contentGroupBy) {
|
||||
customEventData.content_group = context[this.contentGroupBy]!;
|
||||
}
|
||||
|
||||
if (action === 'navigate' && context.extension === 'App') {
|
||||
const extensionId = context.extensionId || context.extension;
|
||||
const category = extensionId ? String(extensionId) : 'App';
|
||||
|
||||
// The legacy default extension was 'App' and the new one is 'app'
|
||||
if (
|
||||
action === 'navigate' &&
|
||||
category.toLocaleLowerCase('en-US').startsWith('app')
|
||||
) {
|
||||
this.capture.event(
|
||||
{
|
||||
category: context.extension || 'App',
|
||||
category,
|
||||
action: 'page_view',
|
||||
label: subject,
|
||||
value,
|
||||
@@ -183,7 +196,7 @@ export class GoogleAnalytics4 implements AnalyticsApi {
|
||||
|
||||
this.capture.event(
|
||||
{
|
||||
category: context.extension || 'App',
|
||||
category,
|
||||
action,
|
||||
label: subject,
|
||||
value,
|
||||
@@ -199,7 +212,7 @@ export class GoogleAnalytics4 implements AnalyticsApi {
|
||||
* @param attributes additional analytics event attributes
|
||||
*/
|
||||
private setEventParameters(
|
||||
context: AnalyticsContextValue,
|
||||
context: AnalyticsContextValue | NewAnalyticsContextValue,
|
||||
attributes: AnalyticsEventAttributes = {},
|
||||
) {
|
||||
const customEventParameters: {
|
||||
|
||||
@@ -4,14 +4,16 @@
|
||||
|
||||
```ts
|
||||
import { AnalyticsApi } from '@backstage/core-plugin-api';
|
||||
import { AnalyticsApi as AnalyticsApi_2 } from '@backstage/frontend-plugin-api';
|
||||
import { AnalyticsEvent } from '@backstage/core-plugin-api';
|
||||
import { AnalyticsEvent as AnalyticsEvent_2 } from '@backstage/frontend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
|
||||
// @public
|
||||
export class NewRelicBrowser implements AnalyticsApi {
|
||||
export class NewRelicBrowser implements AnalyticsApi, AnalyticsApi_2 {
|
||||
// (undocumented)
|
||||
captureEvent(event: AnalyticsEvent): void;
|
||||
captureEvent(event: AnalyticsEvent | AnalyticsEvent_2): void;
|
||||
// (undocumented)
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/core-components": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@newrelic/browser-agent": "^1.236.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
+15
-3
@@ -19,6 +19,10 @@ import {
|
||||
IdentityApi,
|
||||
AnalyticsEvent,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import {
|
||||
AnalyticsApi as NewAnalyicsApi,
|
||||
AnalyticsEvent as NewAnalyticsEvent,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { BrowserAgent } from '@newrelic/browser-agent/loaders/browser-agent';
|
||||
import type { setAPI } from '@newrelic/browser-agent/loaders/api/api';
|
||||
|
||||
@@ -37,7 +41,7 @@ type NewRelicBrowserOptions = {
|
||||
* New Relic Browser API provider for the Backstage Analytics API.
|
||||
* @public
|
||||
*/
|
||||
export class NewRelicBrowser implements AnalyticsApi {
|
||||
export class NewRelicBrowser implements AnalyticsApi, NewAnalyicsApi {
|
||||
private readonly agent: NewRelicAPI;
|
||||
|
||||
private constructor(
|
||||
@@ -121,9 +125,17 @@ export class NewRelicBrowser implements AnalyticsApi {
|
||||
);
|
||||
}
|
||||
|
||||
captureEvent(event: AnalyticsEvent) {
|
||||
captureEvent(event: AnalyticsEvent | NewAnalyticsEvent) {
|
||||
const { context, action, subject, value, attributes } = event;
|
||||
if (action === 'navigate' && context.extension === 'App') {
|
||||
|
||||
const extensionId = context.extensionId || context.extension;
|
||||
const category = extensionId ? String(extensionId) : 'App';
|
||||
|
||||
// The legacy default extension was 'App' and the new one is 'app'
|
||||
if (
|
||||
action === 'navigate' &&
|
||||
category.toLocaleLowerCase('en-US').startsWith('app')
|
||||
) {
|
||||
const interaction = this.agent.interaction();
|
||||
interaction.setName(subject);
|
||||
if (value) {
|
||||
|
||||
@@ -3896,6 +3896,7 @@ __metadata:
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/config": "workspace:^"
|
||||
"@backstage/core-app-api": "workspace:^"
|
||||
"@backstage/errors": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
"@backstage/version-bridge": "workspace:^"
|
||||
@@ -4312,6 +4313,7 @@ __metadata:
|
||||
"@backstage/core-components": "workspace:^"
|
||||
"@backstage/core-plugin-api": "workspace:^"
|
||||
"@backstage/dev-utils": "workspace:^"
|
||||
"@backstage/frontend-plugin-api": "workspace:^"
|
||||
"@testing-library/dom": ^9.0.0
|
||||
"@testing-library/jest-dom": ^6.0.0
|
||||
"@testing-library/react": ^14.0.0
|
||||
@@ -4334,6 +4336,7 @@ __metadata:
|
||||
"@backstage/core-components": "workspace:^"
|
||||
"@backstage/core-plugin-api": "workspace:^"
|
||||
"@backstage/dev-utils": "workspace:^"
|
||||
"@backstage/frontend-plugin-api": "workspace:^"
|
||||
"@testing-library/dom": ^9.0.0
|
||||
"@testing-library/jest-dom": ^6.0.0
|
||||
"@testing-library/react": ^14.0.0
|
||||
@@ -4355,6 +4358,7 @@ __metadata:
|
||||
"@backstage/core-components": "workspace:^"
|
||||
"@backstage/core-plugin-api": "workspace:^"
|
||||
"@backstage/dev-utils": "workspace:^"
|
||||
"@backstage/frontend-plugin-api": "workspace:^"
|
||||
"@newrelic/browser-agent": ^1.236.0
|
||||
"@testing-library/jest-dom": ^6.0.0
|
||||
"@testing-library/react": ^14.0.0
|
||||
|
||||
Reference in New Issue
Block a user