Skip to content

Commit

Permalink
ARC-2316: use backend to send analytics from SPA (#2333)
Browse files Browse the repository at this point in the history
* ARC-2316: use backend to send analytics from SPA

* ARC-2316: fix lint

* ARC-2316: fix tests

* ARC-2316: address comments

* ARC-2316: missing tests

* ARC-2316: export types

* ARC-2316: fix imports

* ARC-2316: fix build
  • Loading branch information
bgvozdev authored Aug 10, 2023
1 parent cdfbe1a commit bf3b547
Show file tree
Hide file tree
Showing 40 changed files with 689 additions and 321 deletions.
5 changes: 2 additions & 3 deletions spa/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,11 @@
"eslint-plugin-react-refresh": "^0.3.4",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"nock": "^13.3.2",
"react-app-rewired": "^2.2.1",
"react-scripts": "^5.0.1",
"ts-jest": "^29.1.1",
"typescript": "^5.0.2"
},
"optionalDependencies": {
"@atlassiansox/analytics-web-client": "^4.15.0"
}
"optionalDependencies": {}
}
112 changes: 112 additions & 0 deletions spa/src/analytics/analytics-proxy-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { analyticsProxyClient } from "./analytics-proxy-client";
import { waitFor } from "@testing-library/react";
import * as nock from "nock";
import { axiosRest } from "../api/axiosInstance";
import { ScreenEventProps, TrackEventProps, UIEventProps } from "./types";

const MY_TOKEN = "myToken";

/* eslint-disable @typescript-eslint/no-explicit-any*/
(global as any).AP = {
getLocation: jest.fn(),
context: {
getContext: jest.fn(),
getToken: (callback: (token: string) => void) => {
callback(MY_TOKEN);
}
},
navigator: {
go: jest.fn(),
reload: jest.fn()
}
};

describe("analytics-proxy-client", () => {
const BASE_URL = "http://localhost";
const ANALYTICS_PROXY_URL = "/rest/app/cloud/analytics-proxy";
const UI_PROPS: UIEventProps = {
actionSubject: "startToConnect", action: "clicked"
};
const TRACK_PROPS: TrackEventProps = {
actionSubject: "finishOAuthFlow", action: "success"
};
const SCREEN_PROPS: ScreenEventProps = {
name: "StartConnectionEntryScreen"
};
const ATTRS = { myAttr: "foobar" };

beforeEach(() => {
axiosRest.defaults.baseURL = BASE_URL;

nock(BASE_URL)
.options(ANALYTICS_PROXY_URL)
.reply(200, undefined, {
"Access-Control-Allow-Methods": "OPTIONS, GET, HEAD, POST",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Authorization",
"Allow": "OPTIONS, GET, HEAD, POST"
});
});

afterEach(() => {
axiosRest.defaults.baseURL = undefined;
});

it("add source to UI events", async () => {
const expectedNock = nock(BASE_URL)
.post(ANALYTICS_PROXY_URL, {
eventType: "ui",
eventProperties: {
...UI_PROPS,
source: "spa"
},
eventAttributes: ATTRS
})
.matchHeader("Authorization", MY_TOKEN)
.reply(202, {});

analyticsProxyClient.sendUIEvent(UI_PROPS, ATTRS);

await waitFor(() => {
expect(expectedNock.isDone()).toBeTruthy();
});
});

it("add source to Track events", async () => {
const expectedNock = nock(BASE_URL)
.post(ANALYTICS_PROXY_URL, {
eventType: "track",
eventProperties: {
...TRACK_PROPS,
source: "spa"
},
eventAttributes: ATTRS
})
.matchHeader("Authorization", MY_TOKEN)
.reply(202, {});

analyticsProxyClient.sendTrackEvent(TRACK_PROPS, ATTRS);

await waitFor(() => {
expect(expectedNock.isDone()).toBeTruthy();
});
});

it("sends Screen event as it is", async () => {
const expectedNock = nock(BASE_URL)
.post(ANALYTICS_PROXY_URL, {
eventType: "screen",
eventProperties: SCREEN_PROPS,
eventAttributes: ATTRS
})
.matchHeader("Authorization", MY_TOKEN)
.reply(202, {});

analyticsProxyClient.sendScreenEvent(SCREEN_PROPS, ATTRS);

await waitFor(() => {
expect(expectedNock.isDone()).toBeTruthy();
});
});

});
28 changes: 28 additions & 0 deletions spa/src/analytics/analytics-proxy-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { AnalyticClient, ScreenEventProps, TrackEventProps, UIEventProps } from "./types";
import { axiosRest } from "../api/axiosInstance";
import { reportError } from "../utils";
const sendAnalytics = (eventType: string, eventProperties: Record<string, unknown>, eventAttributes?: Record<string, unknown>) => {
axiosRest.post(`/rest/app/cloud/analytics-proxy`,
{
eventType,
eventProperties,
eventAttributes
}).catch(reportError);
};
export const analyticsProxyClient: AnalyticClient = {
sendScreenEvent: function(eventProps: ScreenEventProps, attributes?: Record<string, unknown>) {
sendAnalytics("screen", eventProps, attributes);
},
sendUIEvent: function (eventProps: UIEventProps, attributes?: Record<string, unknown>) {
sendAnalytics("ui", {
...eventProps,
source: "spa"
}, attributes);
},
sendTrackEvent: function (eventProps: TrackEventProps, attributes?: Record<string, unknown>) {
sendAnalytics("track", {
...eventProps,
source: "spa"
}, attributes);
}
};
33 changes: 0 additions & 33 deletions spa/src/analytics/context.ts

This file was deleted.

35 changes: 22 additions & 13 deletions spa/src/analytics/index.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
import { AnalyticClient, ScreenNames } from "./types";
import { useEffect } from "react";

import { loadSoxAnalyticClient } from "./sox-analytics-client";
import { noopAnalyticsClient } from "./noop-analytics-client";
import { analyticsProxyClient } from "./analytics-proxy-client";

const analyticsClient: AnalyticClient = loadSoxAnalyticClient() || noopAnalyticsClient;
const analyticsClient: AnalyticClient = analyticsProxyClient;

export default analyticsClient;

export const useEffectScreenEvent = (name: ScreenNames, attributes?: Record<string, string | number>) => {
let lastSent = "";

//stringify so that in the useEffect dependency array it is comparing real content, instead of object instance refence.
//Otherwise it may cause unnecessary fireing to the analytics backend when attribute object instance changed, skewing our analytics dashboard
const jsonStrAttr = JSON.stringify(attributes || {});
export const useEffectScreenEvent = (name: ScreenNames, attributes?: Record<string, unknown>) => {
// TODO: add better serialization because JSON.stringify() does not guarantee the ordering of the elements, which
// means theoretically it could output different strings for the same object
const attributesSerialized = JSON.stringify(attributes || {});

useEffect(() => {
// TODO: for some reason it may fire several events for the same event. Please fix!
if (lastSent === name + attributesSerialized) {
return;
}
analyticsClient.sendScreenEvent({
name,
attributes: {
...JSON.parse(jsonStrAttr)
}
});
}, [ name, jsonStrAttr]);
name
// To make lint happy, otherwise (if attributes) are used it complains that it is not in the list of dependencies
}, JSON.parse(attributesSerialized));

lastSent = name + attributesSerialized;

// Use serialized attributes to make useEffect() compare real content instead of references to an object.
// Otherwise, it may fire unnecessary events when the reference is changed but not the content,
// thus skewing the dashboards.
}, [ name, attributesSerialized ]);

};

7 changes: 0 additions & 7 deletions spa/src/analytics/noop-analytics-client.ts

This file was deleted.

70 changes: 0 additions & 70 deletions spa/src/analytics/sox-analytics-client.ts

This file was deleted.

21 changes: 9 additions & 12 deletions spa/src/analytics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ type UIEventActionSubject =
| "checkBackfillStatus"
| "dropExperienceViaBackButton";

export type UIEventOpts = {
export type UIEventProps = {
actionSubject: UIEventActionSubject,
action: "clicked",
attributes?: Record<string, string | number>
action: "clicked"
};

export type ScreenNames =
Expand All @@ -23,20 +22,18 @@ type TrackEventActionSubject =
| "organisationConnectResponse"
| "installNewOrgInGithubResponse";

export type TrackEventOpts = {
export type TrackEventProps = {
actionSubject: TrackEventActionSubject,
action: "success" | "fail",
attributes?: Record<string, string | number>
action: "success" | "fail"
};

export type ScreenEventOpts = {
name: ScreenNames,
attributes?: Record<string, string | number>
export type ScreenEventProps = {
name: ScreenNames
};

export type AnalyticClient = {
sendScreenEvent: (event: ScreenEventOpts) => void;
sendUIEvent: (event: UIEventOpts) => void;
sendTrackEvent: (event: TrackEventOpts) => void;
sendScreenEvent: (eventProps: ScreenEventProps, attributes?: Record<string, unknown>) => void;
sendUIEvent: (eventProps: UIEventProps, attributes?: Record<string, unknown>) => void;
sendTrackEvent: (eventProps: TrackEventProps, attributes?: Record<string, unknown>) => void;
};

Loading

0 comments on commit bf3b547

Please sign in to comment.