Add analytics tracking for URL previews (#32659)
* Add analytics tracking. * fix import * fix other import too * fixup type * Add test case * Add better testing * make it happier * update lock
This commit is contained in:
@@ -164,7 +164,9 @@ export class PosthogAnalytics {
|
|||||||
dis.register(this.onAction);
|
dis.register(this.onAction);
|
||||||
SettingsStore.monitorSetting("layout", null);
|
SettingsStore.monitorSetting("layout", null);
|
||||||
SettingsStore.monitorSetting("useCompactLayout", null);
|
SettingsStore.monitorSetting("useCompactLayout", null);
|
||||||
|
SettingsStore.monitorSetting("urlPreviewsEnabled", null);
|
||||||
this.onLayoutUpdated();
|
this.onLayoutUpdated();
|
||||||
|
this.onUrlPreviewSettingUpdated(SettingsStore.getValue("urlPreviewsEnabled"));
|
||||||
this.updateCryptoSuperProperty();
|
this.updateCryptoSuperProperty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,11 +190,17 @@ export class PosthogAnalytics {
|
|||||||
this.setProperty("WebLayout", layout);
|
this.setProperty("WebLayout", layout);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private readonly onUrlPreviewSettingUpdated = (value: boolean): void => {
|
||||||
|
this.setProperty("URLPreviewsEnabled", value);
|
||||||
|
};
|
||||||
|
|
||||||
private onAction = (payload: ActionPayload): void => {
|
private onAction = (payload: ActionPayload): void => {
|
||||||
if (payload.action !== Action.SettingUpdated) return;
|
if (payload.action !== Action.SettingUpdated) return;
|
||||||
const settingsPayload = payload as SettingUpdatedPayload;
|
const settingsPayload = payload as SettingUpdatedPayload;
|
||||||
if (["layout", "useCompactLayout"].includes(settingsPayload.settingName)) {
|
if (["layout", "useCompactLayout"].includes(settingsPayload.settingName)) {
|
||||||
this.onLayoutUpdated();
|
this.onLayoutUpdated();
|
||||||
|
} else if (settingsPayload.settingName === "urlPreviewsEnabled" && !settingsPayload.roomId) {
|
||||||
|
this.onUrlPreviewSettingUpdated(settingsPayload.newValue as boolean);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
Copyright 2024 New Vector Ltd.
|
Copyright 2024 New Vector Ltd.
|
||||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
@@ -11,11 +12,14 @@ import { type WebScreen as ScreenEvent } from "@matrix-org/analytics-events/type
|
|||||||
import { type Interaction as InteractionEvent } from "@matrix-org/analytics-events/types/typescript/Interaction";
|
import { type Interaction as InteractionEvent } from "@matrix-org/analytics-events/types/typescript/Interaction";
|
||||||
import { type PinUnpinAction } from "@matrix-org/analytics-events/types/typescript/PinUnpinAction";
|
import { type PinUnpinAction } from "@matrix-org/analytics-events/types/typescript/PinUnpinAction";
|
||||||
import { type RoomListSortingAlgorithmChanged } from "@matrix-org/analytics-events/types/typescript/RoomListSortingAlgorithmChanged";
|
import { type RoomListSortingAlgorithmChanged } from "@matrix-org/analytics-events/types/typescript/RoomListSortingAlgorithmChanged";
|
||||||
|
import { type UrlPreviewRendered } from "@matrix-org/analytics-events/types/typescript/UrlPreviewRendered";
|
||||||
|
import { type UrlPreview } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
import PageType from "./PageTypes";
|
import PageType from "./PageTypes";
|
||||||
import Views from "./Views";
|
import Views from "./Views";
|
||||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||||
import { SortingAlgorithm } from "./stores/room-list-v3/skip-list/sorters";
|
import { SortingAlgorithm } from "./stores/room-list-v3/skip-list/sorters";
|
||||||
|
import { LruCache } from "./utils/LruCache";
|
||||||
|
|
||||||
export type ScreenName = ScreenEvent["$current_url"];
|
export type ScreenName = ScreenEvent["$current_url"];
|
||||||
export type InteractionName = InteractionEvent["name"];
|
export type InteractionName = InteractionEvent["name"];
|
||||||
@@ -49,6 +53,8 @@ const SortingAlgorithmMap: Record<SortingAlgorithm, RoomListSortingAlgorithmChan
|
|||||||
export default class PosthogTrackers {
|
export default class PosthogTrackers {
|
||||||
private static internalInstance: PosthogTrackers;
|
private static internalInstance: PosthogTrackers;
|
||||||
|
|
||||||
|
private readonly previewedEventIds = new LruCache<string, true>(1000);
|
||||||
|
|
||||||
public static get instance(): PosthogTrackers {
|
public static get instance(): PosthogTrackers {
|
||||||
if (!PosthogTrackers.internalInstance) {
|
if (!PosthogTrackers.internalInstance) {
|
||||||
PosthogTrackers.internalInstance = new PosthogTrackers();
|
PosthogTrackers.internalInstance = new PosthogTrackers();
|
||||||
@@ -136,6 +142,29 @@ export default class PosthogTrackers {
|
|||||||
newAlgorithm: SortingAlgorithmMap[newAlgorithm],
|
newAlgorithm: SortingAlgorithmMap[newAlgorithm],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track if an event has had a previewed rendered in the client.
|
||||||
|
* This function makes a best-effort attempt to prevent double counting.
|
||||||
|
*
|
||||||
|
* @param eventId EventID for deduplication.
|
||||||
|
* @param isEncrypted Whether the event (and effectively the room) was encrypted.
|
||||||
|
* @param previews The previews generated from the event.
|
||||||
|
*/
|
||||||
|
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: UrlPreview[]): void {
|
||||||
|
// Discount any previews that we have already tracked.
|
||||||
|
if (this.previewedEventIds.get(eventId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PosthogAnalytics.instance.trackEvent<UrlPreviewRendered>({
|
||||||
|
eventName: "UrlPreviewRendered",
|
||||||
|
previewKind: "LegacyCard",
|
||||||
|
hasThumbnail: previews.some((p) => !!p.image),
|
||||||
|
previewCount: previews.length,
|
||||||
|
encryptedRoom: isEncrypted,
|
||||||
|
});
|
||||||
|
this.previewedEventIds.set(eventId, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PosthogScreenTracker extends PureComponent<{ screenName: ScreenName }> {
|
export class PosthogScreenTracker extends PureComponent<{ screenName: ScreenName }> {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
useCreateAutoDisposedViewModel,
|
useCreateAutoDisposedViewModel,
|
||||||
EventContentBodyView,
|
EventContentBodyView,
|
||||||
LINKIFIED_DATA_ATTRIBUTE,
|
LINKIFIED_DATA_ATTRIBUTE,
|
||||||
|
useViewModel,
|
||||||
} from "@element-hq/web-shared-components";
|
} from "@element-hq/web-shared-components";
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ import { UrlPreviewGroupViewModel } from "../../../viewmodels/message-body/UrlPr
|
|||||||
import { useMediaVisible } from "../../../hooks/useMediaVisible.ts";
|
import { useMediaVisible } from "../../../hooks/useMediaVisible.ts";
|
||||||
import ImageView from "../elements/ImageView.tsx";
|
import ImageView from "../elements/ImageView.tsx";
|
||||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
|
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
|
||||||
|
import PosthogTrackers from "../../../PosthogTrackers.ts";
|
||||||
|
|
||||||
const logger = rootLogger.getChild("TextualBody");
|
const logger = rootLogger.getChild("TextualBody");
|
||||||
|
|
||||||
@@ -427,5 +429,14 @@ export default function TextualBody(props: IBodyProps): React.ReactElement {
|
|||||||
})();
|
})();
|
||||||
}, [vm, props.showUrlPreview, mediaVisible]);
|
}, [vm, props.showUrlPreview, mediaVisible]);
|
||||||
|
|
||||||
|
const { previews } = useViewModel(vm);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (previews.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PosthogTrackers.instance.trackUrlPreview(props.mxEvent.getId()!, props.mxEvent.isEncrypted(), previews);
|
||||||
|
}, [props.mxEvent, previews]);
|
||||||
|
|
||||||
return <InnerTextualBody urlPreviewViewModel={vm} {...props} />;
|
return <InnerTextualBody urlPreviewViewModel={vm} {...props} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ import {
|
|||||||
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
|
||||||
import { type IPreviewUrlResponse, type MatrixClient, MatrixError, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
import { type IPreviewUrlResponse, type MatrixClient, MatrixError, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
import { decode } from "html-entities";
|
import { decode } from "html-entities";
|
||||||
|
import { type UrlPreviewVisibilityChanged } from "@matrix-org/analytics-events/types/typescript/UrlPreviewVisibilityChanged";
|
||||||
|
|
||||||
import { isPermalinkHost } from "../../utils/permalinks/Permalinks";
|
import { isPermalinkHost } from "../../utils/permalinks/Permalinks";
|
||||||
import { mediaFromMxc } from "../../customisations/Media";
|
import { mediaFromMxc } from "../../customisations/Media";
|
||||||
import PlatformPeg from "../../PlatformPeg";
|
import PlatformPeg from "../../PlatformPeg";
|
||||||
import { thumbHeight } from "../../ImageUtils";
|
import { thumbHeight } from "../../ImageUtils";
|
||||||
import SettingsStore from "../../settings/SettingsStore";
|
import SettingsStore from "../../settings/SettingsStore";
|
||||||
|
import { PosthogAnalytics } from "../../PosthogAnalytics";
|
||||||
|
|
||||||
const logger = rootLogger.getChild("UrlPreviewGroupViewModel");
|
const logger = rootLogger.getChild("UrlPreviewGroupViewModel");
|
||||||
|
|
||||||
@@ -404,6 +406,13 @@ export class UrlPreviewGroupViewModel
|
|||||||
// FIXME: persist this somewhere smarter than local storage
|
// FIXME: persist this somewhere smarter than local storage
|
||||||
globalThis.localStorage?.setItem(this.storageKey, "1");
|
globalThis.localStorage?.setItem(this.storageKey, "1");
|
||||||
this.urlPreviewEnabledByUser = false;
|
this.urlPreviewEnabledByUser = false;
|
||||||
|
PosthogAnalytics.instance.trackEvent<UrlPreviewVisibilityChanged>({
|
||||||
|
eventName: "UrlPreviewVisibilityChanged",
|
||||||
|
previewKind: "LegacyCard",
|
||||||
|
hasThumbnail: this.snapshot.current.previews.some((p) => !!p.image),
|
||||||
|
previewCount: this.snapshot.current.previews.length,
|
||||||
|
visible: this.urlPreviewEnabledByUser,
|
||||||
|
});
|
||||||
return this.computeSnapshot();
|
return this.computeSnapshot();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
Copyright 2024 New Vector Ltd.
|
Copyright 2024 New Vector Ltd.
|
||||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
@@ -205,6 +206,10 @@ describe("PosthogAnalytics", () => {
|
|||||||
analytics.setAnonymity(Anonymity.Pseudonymous);
|
analytics.setAnonymity(Anonymity.Pseudonymous);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
SettingsStore.reset();
|
||||||
|
});
|
||||||
|
|
||||||
it("should send layout IRC correctly", async () => {
|
it("should send layout IRC correctly", async () => {
|
||||||
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
|
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
|
||||||
defaultDispatcher.dispatch(
|
defaultDispatcher.dispatch(
|
||||||
@@ -217,7 +222,7 @@ describe("PosthogAnalytics", () => {
|
|||||||
analytics.trackEvent<ITestEvent>({
|
analytics.trackEvent<ITestEvent>({
|
||||||
eventName: "JestTestEvents",
|
eventName: "JestTestEvents",
|
||||||
});
|
});
|
||||||
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
|
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
|
||||||
WebLayout: "IRC",
|
WebLayout: "IRC",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -234,7 +239,7 @@ describe("PosthogAnalytics", () => {
|
|||||||
analytics.trackEvent<ITestEvent>({
|
analytics.trackEvent<ITestEvent>({
|
||||||
eventName: "JestTestEvents",
|
eventName: "JestTestEvents",
|
||||||
});
|
});
|
||||||
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
|
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
|
||||||
WebLayout: "Bubble",
|
WebLayout: "Bubble",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -251,7 +256,7 @@ describe("PosthogAnalytics", () => {
|
|||||||
analytics.trackEvent<ITestEvent>({
|
analytics.trackEvent<ITestEvent>({
|
||||||
eventName: "JestTestEvents",
|
eventName: "JestTestEvents",
|
||||||
});
|
});
|
||||||
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
|
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
|
||||||
WebLayout: "Group",
|
WebLayout: "Group",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -269,12 +274,51 @@ describe("PosthogAnalytics", () => {
|
|||||||
analytics.trackEvent<ITestEvent>({
|
analytics.trackEvent<ITestEvent>({
|
||||||
eventName: "JestTestEvents",
|
eventName: "JestTestEvents",
|
||||||
});
|
});
|
||||||
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
|
console.log(mocked(fakePosthog).capture.mock.calls[0]);
|
||||||
|
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
|
||||||
WebLayout: "Compact",
|
WebLayout: "Compact",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("UrlPreviews", () => {
|
||||||
|
let analytics: PosthogAnalytics;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
SdkConfig.put({
|
||||||
|
brand: "Testing",
|
||||||
|
posthog: {
|
||||||
|
project_api_key: "foo",
|
||||||
|
api_host: "bar",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
analytics = new PosthogAnalytics(fakePosthog);
|
||||||
|
analytics.setAnonymity(Anonymity.Pseudonymous);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
SdkConfig.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set UrlPreviewsEnabled on change", async () => {
|
||||||
|
defaultDispatcher.dispatch(
|
||||||
|
{
|
||||||
|
action: Action.SettingUpdated,
|
||||||
|
settingName: "urlPreviewsEnabled",
|
||||||
|
newValue: true,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
analytics.trackEvent<ITestEvent>({
|
||||||
|
eventName: "JestTestEvents",
|
||||||
|
});
|
||||||
|
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
|
||||||
|
URLPreviewsEnabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("CryptoSdk", () => {
|
describe("CryptoSdk", () => {
|
||||||
let analytics: PosthogAnalytics;
|
let analytics: PosthogAnalytics;
|
||||||
const getFakeClient = (): MatrixClient =>
|
const getFakeClient = (): MatrixClient =>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PosthogAnalytics } from "../../src/PosthogAnalytics";
|
||||||
|
import PosthogTrackers from "../../src/PosthogTrackers";
|
||||||
|
|
||||||
|
describe("PosthogTrackers", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks URL Previews", () => {
|
||||||
|
jest.spyOn(PosthogAnalytics.instance, "trackEvent");
|
||||||
|
const tracker = new PosthogTrackers();
|
||||||
|
tracker.trackUrlPreview("$123456", false, [
|
||||||
|
{
|
||||||
|
title: "A preview",
|
||||||
|
image: {
|
||||||
|
imageThumb: "abc",
|
||||||
|
imageFull: "abc",
|
||||||
|
},
|
||||||
|
link: "a-link",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
tracker.trackUrlPreview("$123456", false, [
|
||||||
|
{
|
||||||
|
title: "A second preview",
|
||||||
|
link: "a-link",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// Ignores subsequent calls.
|
||||||
|
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
|
||||||
|
eventName: "UrlPreviewRendered",
|
||||||
|
previewKind: "LegacyCard",
|
||||||
|
hasThumbnail: true,
|
||||||
|
previewCount: 1,
|
||||||
|
encryptedRoom: false,
|
||||||
|
});
|
||||||
|
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+1
@@ -5979,6 +5979,7 @@ packages:
|
|||||||
'@xmldom/xmldom@0.8.11':
|
'@xmldom/xmldom@0.8.11':
|
||||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
|
deprecated: this version has critical issues, please update to the latest version
|
||||||
|
|
||||||
'@xtuc/ieee754@1.2.0':
|
'@xtuc/ieee754@1.2.0':
|
||||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||||
|
|||||||
Reference in New Issue
Block a user