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:
James Salter 2021-07-21 14:57:24 +01:00
parent 6da3cc8ca1
commit 4c6b0d35ad
2 changed files with 136 additions and 101 deletions

View file

@ -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);
} }
} }

View file

@ -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);
});
}); });
}); });