Improve analytics interface
* Make it an error to call it before its initialised, and separately track whether its been enabled * Use anonmity enum in the public interface * Properly await upstream calls * Fix accidental test fixture cross-reliance
This commit is contained in:
parent
6da3cc8ca1
commit
4c6b0d35ad
2 changed files with 136 additions and 101 deletions
|
@ -66,10 +66,11 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PosthogAnalytics {
|
export class PosthogAnalytics {
|
||||||
private onlyTrackAnonymousEvents = false;
|
private anonymity = Anonymity.Anonymous;
|
||||||
private initialised = false;
|
private initialised = false;
|
||||||
private posthog?: PostHog = null;
|
private posthog?: PostHog = null;
|
||||||
private redactedCurrentLocation = null;
|
private redactedCurrentLocation = null;
|
||||||
|
private enabled = false;
|
||||||
|
|
||||||
private static _instance = null;
|
private static _instance = null;
|
||||||
|
|
||||||
|
@ -84,12 +85,8 @@ export class PosthogAnalytics {
|
||||||
this.posthog = posthog;
|
this.posthog = posthog;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async init(onlyTrackAnonymousEvents: boolean) {
|
public async init(anonymity: Anonymity) {
|
||||||
if (Boolean(navigator.doNotTrack === "1")) {
|
this.anonymity = Boolean(navigator.doNotTrack === "1") ? Anonymity.Anonymous : anonymity;
|
||||||
this.initialised = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.onlyTrackAnonymousEvents = onlyTrackAnonymousEvents;
|
|
||||||
|
|
||||||
const posthogConfig = SdkConfig.get()["posthog"];
|
const posthogConfig = SdkConfig.get()["posthog"];
|
||||||
if (posthogConfig) {
|
if (posthogConfig) {
|
||||||
|
@ -105,6 +102,9 @@ export class PosthogAnalytics {
|
||||||
sanitize_properties: this.sanitizeProperties.bind(this),
|
sanitize_properties: this.sanitizeProperties.bind(this),
|
||||||
});
|
});
|
||||||
this.initialised = true;
|
this.initialised = true;
|
||||||
|
this.enabled = true;
|
||||||
|
} else {
|
||||||
|
this.enabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,7 +112,7 @@ export class PosthogAnalytics {
|
||||||
// TODO only calculate this when the location changes as its expensive
|
// TODO only calculate this when the location changes as its expensive
|
||||||
const { origin, hash, pathname } = window.location;
|
const { origin, hash, pathname } = window.location;
|
||||||
this.redactedCurrentLocation = await getRedactedCurrentLocation(
|
this.redactedCurrentLocation = await getRedactedCurrentLocation(
|
||||||
origin, hash, pathname, this.onlyTrackAnonymousEvents ? Anonymity.Anonymous : Anonymity.Pseudonymous);
|
origin, hash, pathname, this.anonymity);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sanitizeProperties(properties: posthog.Properties, _: string): posthog.Properties {
|
private sanitizeProperties(properties: posthog.Properties, _: string): posthog.Properties {
|
||||||
|
@ -123,7 +123,7 @@ export class PosthogAnalytics {
|
||||||
// updating it involves async, which this callback is not
|
// updating it involves async, which this callback is not
|
||||||
properties['$current_url'] = this.redactedCurrentLocation;
|
properties['$current_url'] = this.redactedCurrentLocation;
|
||||||
|
|
||||||
if (this.onlyTrackAnonymousEvents) {
|
if (this.anonymity == Anonymity.Anonymous) {
|
||||||
// drop referrer information for anonymous users
|
// drop referrer information for anonymous users
|
||||||
properties['$referrer'] = null;
|
properties['$referrer'] = null;
|
||||||
properties['$referring_domain'] = null;
|
properties['$referring_domain'] = null;
|
||||||
|
@ -138,21 +138,41 @@ export class PosthogAnalytics {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async identifyUser(userId: string) {
|
public async identifyUser(userId: string) {
|
||||||
if (this.onlyTrackAnonymousEvents) return;
|
if (this.anonymity == Anonymity.Anonymous) return;
|
||||||
this.posthog.identify(await hashHex(userId));
|
this.posthog.identify(await hashHex(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public isInitialised(): boolean {
|
public isInitialised() {
|
||||||
return this.initialised;
|
return this.initialised;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setOnlyTrackAnonymousEvents(enabled: boolean) {
|
public isEnabled() {
|
||||||
this.onlyTrackAnonymousEvents = enabled;
|
return this.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async capture(eventName: string, properties: posthog.Properties, anonymity: Anonymity) {
|
public setAnonymity(anonymity: Anonymity) {
|
||||||
if (!this.initialised) return;
|
this.anonymity = anonymity;
|
||||||
await this.updateRedactedCurrentLocation(anonymity);
|
}
|
||||||
|
|
||||||
|
public getAnonymity() {
|
||||||
|
return this.anonymity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public logout() {
|
||||||
|
if (this.enabled) {
|
||||||
|
this.posthog.reset();
|
||||||
|
}
|
||||||
|
this.setAnonymity(Anonymity.Anonymous);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async capture(eventName: string, properties: posthog.Properties) {
|
||||||
|
if (!this.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.initialised) {
|
||||||
|
throw Error("Tried to track event before PoshogAnalytics.init has completed");
|
||||||
|
}
|
||||||
|
await this.updateRedactedCurrentLocation();
|
||||||
this.posthog.capture(eventName, properties);
|
this.posthog.capture(eventName, properties);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,15 +180,15 @@ export class PosthogAnalytics {
|
||||||
eventName: E["eventName"],
|
eventName: E["eventName"],
|
||||||
properties: E["properties"],
|
properties: E["properties"],
|
||||||
) {
|
) {
|
||||||
if (this.onlyTrackAnonymousEvents) return;
|
if (this.anonymity == Anonymity.Anonymous) return;
|
||||||
this.capture(eventName, properties, Anonymity.Pseudonyomous);
|
await this.capture(eventName, properties);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async trackAnonymousEvent<E extends IAnonymousEvent>(
|
public async trackAnonymousEvent<E extends IAnonymousEvent>(
|
||||||
eventName: E["eventName"],
|
eventName: E["eventName"],
|
||||||
properties: E["properties"],
|
properties: E["properties"],
|
||||||
) {
|
) {
|
||||||
this.capture(eventName, properties, Anonymity.Anonymous);
|
await this.capture(eventName, properties);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async trackRoomEvent<E extends IRoomEvent>(
|
public async trackRoomEvent<E extends IRoomEvent>(
|
||||||
|
@ -180,7 +200,7 @@ export class PosthogAnalytics {
|
||||||
...properties,
|
...properties,
|
||||||
hashedRoomId: roomId ? await hashHex(roomId) : null,
|
hashedRoomId: roomId ? await hashHex(roomId) : null,
|
||||||
};
|
};
|
||||||
this.trackPseudonymousEvent(eventName, updatedProperties);
|
await this.trackPseudonymousEvent(eventName, updatedProperties);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -46,106 +46,121 @@ describe("PosthogAnalytics", () => {
|
||||||
window.crypto = null;
|
window.crypto = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Should not initialise if DNT is enabled", () => {
|
describe("Initialisation", () => {
|
||||||
navigator.doNotTrack = "1";
|
it("Should not initialise if config is not set", async () => {
|
||||||
analytics.init(false);
|
jest.spyOn(SdkConfig, "get").mockReturnValue({});
|
||||||
expect(analytics.isInitialised()).toBe(false);
|
await analytics.init(Anonymity.Pseudonymous);
|
||||||
});
|
expect(analytics.isEnabled()).toBe(false);
|
||||||
|
|
||||||
it("Should not initialise if config is not set", () => {
|
|
||||||
jest.spyOn(SdkConfig, "get").mockReturnValue({});
|
|
||||||
analytics.init(false);
|
|
||||||
expect(analytics.isInitialised()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Should initialise if config is set", () => {
|
|
||||||
jest.spyOn(SdkConfig, "get").mockReturnValue({
|
|
||||||
posthog: {
|
|
||||||
projectApiKey: "foo",
|
|
||||||
apiHost: "bar",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
analytics.init(false);
|
|
||||||
expect(analytics.isInitialised()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Should pass track() to posthog", async () => {
|
it("Should initialise if config is set", async () => {
|
||||||
analytics.init(false);
|
jest.spyOn(SdkConfig, "get").mockReturnValue({
|
||||||
await analytics.trackAnonymousEvent<ITestEvent>("jest_test_event", {
|
posthog: {
|
||||||
foo: "bar",
|
projectApiKey: "foo",
|
||||||
|
apiHost: "bar",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await analytics.init(Anonymity.Pseudonymous);
|
||||||
|
expect(analytics.isInitialised()).toBe(true);
|
||||||
|
expect(analytics.isEnabled()).toBe(true);
|
||||||
});
|
});
|
||||||
expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event");
|
|
||||||
expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ foo: "bar" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Should pass trackRoomEvent to posthog", async () => {
|
it("Should force anonymous if DNT is enabled", async () => {
|
||||||
analytics.init(false);
|
navigator.doNotTrack = "1";
|
||||||
const roomId = "42";
|
await analytics.init(Anonymity.Pseudonymous);
|
||||||
await analytics.trackRoomEvent<IRoomEvent>("jest_test_event", roomId, {
|
expect(analytics.getAnonymity()).toBe(Anonymity.Anonymous);
|
||||||
foo: "bar",
|
|
||||||
});
|
|
||||||
expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event");
|
|
||||||
expect(fakePosthog.capture.mock.calls[0][1]).toEqual({
|
|
||||||
foo: "bar",
|
|
||||||
hashedRoomId: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Should silently not track if not inititalised", async () => {
|
describe("Tracking", () => {
|
||||||
await analytics.trackAnonymousEvent<ITestEvent>("jest_test_event", {
|
beforeEach(() => {
|
||||||
foo: "bar",
|
navigator.doNotTrack = null;
|
||||||
|
jest.spyOn(SdkConfig, "get").mockReturnValue({
|
||||||
|
posthog: {
|
||||||
|
projectApiKey: "foo",
|
||||||
|
apiHost: "bar",
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
expect(fakePosthog.capture.mock.calls.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Should not track non-anonymous messages if onlyTrackAnonymousEvents is true", async () => {
|
it("Should pass track() to posthog", async () => {
|
||||||
analytics.init(true);
|
await analytics.init(Anonymity.Pseudonymous);
|
||||||
await analytics.trackPseudonymousEvent<ITestEvent>("jest_test_event", {
|
await analytics.trackAnonymousEvent<ITestEvent>("jest_test_event", {
|
||||||
foo: "bar",
|
foo: "bar",
|
||||||
|
});
|
||||||
|
expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event");
|
||||||
|
expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ foo: "bar" });
|
||||||
});
|
});
|
||||||
expect(fakePosthog.capture.mock.calls.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Should identify the user to posthog if onlyTrackAnonymousEvents is false", async () => {
|
it("Should pass trackRoomEvent to posthog", async () => {
|
||||||
analytics.init(false);
|
await analytics.init(Anonymity.Pseudonymous);
|
||||||
await analytics.identifyUser("foo");
|
const roomId = "42";
|
||||||
expect(fakePosthog.identify.mock.calls[0][0])
|
await analytics.trackRoomEvent<IRoomEvent>("jest_test_event", roomId, {
|
||||||
.toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae");
|
foo: "bar",
|
||||||
});
|
});
|
||||||
|
expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event");
|
||||||
|
expect(fakePosthog.capture.mock.calls[0][1]).toEqual({
|
||||||
|
foo: "bar",
|
||||||
|
hashedRoomId: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("Should not identify the user to posthog if onlyTrackAnonymousEvents is true", async () => {
|
it("Should silently not track if not inititalised", async () => {
|
||||||
analytics.init(true);
|
await analytics.trackAnonymousEvent<ITestEvent>("jest_test_event", {
|
||||||
await analytics.identifyUser("foo");
|
foo: "bar",
|
||||||
expect(fakePosthog.identify.mock.calls.length).toBe(0);
|
});
|
||||||
});
|
expect(fakePosthog.capture.mock.calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("Should pseudonymise a location of a known screen", async () => {
|
it("Should not track non-anonymous messages if anonymous", async () => {
|
||||||
const location = await getRedactedCurrentLocation(
|
await analytics.init(Anonymity.Anonymous);
|
||||||
"https://foo.bar", "#/register/some/pii", "/", Anonymity.Pseudonymous);
|
await analytics.trackPseudonymousEvent<ITestEvent>("jest_test_event", {
|
||||||
expect(location).toBe(
|
foo: "bar",
|
||||||
`https://foo.bar/#/register/\
|
});
|
||||||
|
expect(fakePosthog.capture.mock.calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should pseudonymise a location of a known screen", async () => {
|
||||||
|
const location = await getRedactedCurrentLocation(
|
||||||
|
"https://foo.bar", "#/register/some/pii", "/", Anonymity.Pseudonymous);
|
||||||
|
expect(location).toBe(
|
||||||
|
`https://foo.bar/#/register/\
|
||||||
a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\
|
a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\
|
||||||
bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`);
|
bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Should anonymise a location of a known screen", async () => {
|
it("Should anonymise a location of a known screen", async () => {
|
||||||
const location = await getRedactedCurrentLocation(
|
const location = await getRedactedCurrentLocation(
|
||||||
"https://foo.bar", "#/register/some/pii", "/", Anonymity.Anonymous);
|
"https://foo.bar", "#/register/some/pii", "/", Anonymity.Anonymous);
|
||||||
expect(location).toBe("https://foo.bar/#/register/<redacted>/<redacted>");
|
expect(location).toBe("https://foo.bar/#/register/<redacted>/<redacted>");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Should pseudonymise a location of an unknown screen", async () => {
|
it("Should pseudonymise a location of an unknown screen", async () => {
|
||||||
const location = await getRedactedCurrentLocation(
|
const location = await getRedactedCurrentLocation(
|
||||||
"https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Pseudonymous);
|
"https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Pseudonymous);
|
||||||
expect(location).toBe(
|
expect(location).toBe(
|
||||||
`https://foo.bar/#/<redacted_screen_name>/\
|
`https://foo.bar/#/<redacted_screen_name>/\
|
||||||
a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\
|
a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\
|
||||||
bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`);
|
bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Should anonymise a location of an unknown screen", async () => {
|
it("Should anonymise a location of an unknown screen", async () => {
|
||||||
const location = await getRedactedCurrentLocation(
|
const location = await getRedactedCurrentLocation(
|
||||||
"https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Anonymous);
|
"https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Anonymous);
|
||||||
expect(location).toBe("https://foo.bar/#/<redacted_screen_name>/<redacted>/<redacted>");
|
expect(location).toBe("https://foo.bar/#/<redacted_screen_name>/<redacted>/<redacted>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should identify the user to posthog if pseudonymous", async () => {
|
||||||
|
await analytics.init(Anonymity.Pseudonymous);
|
||||||
|
await analytics.identifyUser("foo");
|
||||||
|
expect(fakePosthog.identify.mock.calls[0][0])
|
||||||
|
.toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should not identify the user to posthog if anonymous", async () => {
|
||||||
|
await analytics.init(Anonymity.Anonymous);
|
||||||
|
await analytics.identifyUser("foo");
|
||||||
|
expect(fakePosthog.identify.mock.calls.length).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue