Skip to content

Commit e0cf78b

Browse files
authored
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
1 parent f28fca7 commit e0cf78b

7 files changed

Lines changed: 151 additions & 4 deletions

File tree

apps/web/src/PosthogAnalytics.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,9 @@ export class PosthogAnalytics {
164164
dis.register(this.onAction);
165165
SettingsStore.monitorSetting("layout", null);
166166
SettingsStore.monitorSetting("useCompactLayout", null);
167+
SettingsStore.monitorSetting("urlPreviewsEnabled", null);
167168
this.onLayoutUpdated();
169+
this.onUrlPreviewSettingUpdated(SettingsStore.getValue("urlPreviewsEnabled"));
168170
this.updateCryptoSuperProperty();
169171
}
170172

@@ -188,11 +190,17 @@ export class PosthogAnalytics {
188190
this.setProperty("WebLayout", layout);
189191
};
190192

193+
private readonly onUrlPreviewSettingUpdated = (value: boolean): void => {
194+
this.setProperty("URLPreviewsEnabled", value);
195+
};
196+
191197
private onAction = (payload: ActionPayload): void => {
192198
if (payload.action !== Action.SettingUpdated) return;
193199
const settingsPayload = payload as SettingUpdatedPayload;
194200
if (["layout", "useCompactLayout"].includes(settingsPayload.settingName)) {
195201
this.onLayoutUpdated();
202+
} else if (settingsPayload.settingName === "urlPreviewsEnabled" && !settingsPayload.roomId) {
203+
this.onUrlPreviewSettingUpdated(settingsPayload.newValue as boolean);
196204
}
197205
};
198206

apps/web/src/PosthogTrackers.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/*
2+
Copyright 2026 Element Creations Ltd.
23
Copyright 2024 New Vector Ltd.
34
Copyright 2022 The Matrix.org Foundation C.I.C.
45
@@ -11,11 +12,14 @@ import { type WebScreen as ScreenEvent } from "@matrix-org/analytics-events/type
1112
import { type Interaction as InteractionEvent } from "@matrix-org/analytics-events/types/typescript/Interaction";
1213
import { type PinUnpinAction } from "@matrix-org/analytics-events/types/typescript/PinUnpinAction";
1314
import { type RoomListSortingAlgorithmChanged } from "@matrix-org/analytics-events/types/typescript/RoomListSortingAlgorithmChanged";
15+
import { type UrlPreviewRendered } from "@matrix-org/analytics-events/types/typescript/UrlPreviewRendered";
16+
import { type UrlPreview } from "@element-hq/web-shared-components";
1417

1518
import PageType from "./PageTypes";
1619
import Views from "./Views";
1720
import { PosthogAnalytics } from "./PosthogAnalytics";
1821
import { SortingAlgorithm } from "./stores/room-list-v3/skip-list/sorters";
22+
import { LruCache } from "./utils/LruCache";
1923

2024
export type ScreenName = ScreenEvent["$current_url"];
2125
export type InteractionName = InteractionEvent["name"];
@@ -49,6 +53,8 @@ const SortingAlgorithmMap: Record<SortingAlgorithm, RoomListSortingAlgorithmChan
4953
export default class PosthogTrackers {
5054
private static internalInstance: PosthogTrackers;
5155

56+
private readonly previewedEventIds = new LruCache<string, true>(1000);
57+
5258
public static get instance(): PosthogTrackers {
5359
if (!PosthogTrackers.internalInstance) {
5460
PosthogTrackers.internalInstance = new PosthogTrackers();
@@ -136,6 +142,29 @@ export default class PosthogTrackers {
136142
newAlgorithm: SortingAlgorithmMap[newAlgorithm],
137143
});
138144
}
145+
146+
/**
147+
* Track if an event has had a previewed rendered in the client.
148+
* This function makes a best-effort attempt to prevent double counting.
149+
*
150+
* @param eventId EventID for deduplication.
151+
* @param isEncrypted Whether the event (and effectively the room) was encrypted.
152+
* @param previews The previews generated from the event.
153+
*/
154+
public trackUrlPreview(eventId: string, isEncrypted: boolean, previews: UrlPreview[]): void {
155+
// Discount any previews that we have already tracked.
156+
if (this.previewedEventIds.get(eventId)) {
157+
return;
158+
}
159+
PosthogAnalytics.instance.trackEvent<UrlPreviewRendered>({
160+
eventName: "UrlPreviewRendered",
161+
previewKind: "LegacyCard",
162+
hasThumbnail: previews.some((p) => !!p.image),
163+
previewCount: previews.length,
164+
encryptedRoom: isEncrypted,
165+
});
166+
this.previewedEventIds.set(eventId, true);
167+
}
139168
}
140169

141170
export class PosthogScreenTracker extends PureComponent<{ screenName: ScreenName }> {

apps/web/src/components/views/messages/TextualBody.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
useCreateAutoDisposedViewModel,
1515
EventContentBodyView,
1616
LINKIFIED_DATA_ATTRIBUTE,
17+
useViewModel,
1718
} from "@element-hq/web-shared-components";
1819
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
1920

@@ -39,6 +40,7 @@ import { UrlPreviewGroupViewModel } from "../../../viewmodels/message-body/UrlPr
3940
import { useMediaVisible } from "../../../hooks/useMediaVisible.ts";
4041
import ImageView from "../elements/ImageView.tsx";
4142
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
43+
import PosthogTrackers from "../../../PosthogTrackers.ts";
4244

4345
const logger = rootLogger.getChild("TextualBody");
4446

@@ -427,5 +429,14 @@ export default function TextualBody(props: IBodyProps): React.ReactElement {
427429
})();
428430
}, [vm, props.showUrlPreview, mediaVisible]);
429431

432+
const { previews } = useViewModel(vm);
433+
434+
useEffect(() => {
435+
if (previews.length === 0) {
436+
return;
437+
}
438+
PosthogTrackers.instance.trackUrlPreview(props.mxEvent.getId()!, props.mxEvent.isEncrypted(), previews);
439+
}, [props.mxEvent, previews]);
440+
430441
return <InnerTextualBody urlPreviewViewModel={vm} {...props} />;
431442
}

apps/web/src/viewmodels/message-body/UrlPreviewGroupViewModel.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ import {
1414
import { logger as rootLogger } from "matrix-js-sdk/src/logger";
1515
import { type IPreviewUrlResponse, type MatrixClient, MatrixError, type MatrixEvent } from "matrix-js-sdk/src/matrix";
1616
import { decode } from "html-entities";
17+
import { type UrlPreviewVisibilityChanged } from "@matrix-org/analytics-events/types/typescript/UrlPreviewVisibilityChanged";
1718

1819
import { isPermalinkHost } from "../../utils/permalinks/Permalinks";
1920
import { mediaFromMxc } from "../../customisations/Media";
2021
import PlatformPeg from "../../PlatformPeg";
2122
import { thumbHeight } from "../../ImageUtils";
2223
import SettingsStore from "../../settings/SettingsStore";
24+
import { PosthogAnalytics } from "../../PosthogAnalytics";
2325

2426
const logger = rootLogger.getChild("UrlPreviewGroupViewModel");
2527

@@ -404,6 +406,13 @@ export class UrlPreviewGroupViewModel
404406
// FIXME: persist this somewhere smarter than local storage
405407
globalThis.localStorage?.setItem(this.storageKey, "1");
406408
this.urlPreviewEnabledByUser = false;
409+
PosthogAnalytics.instance.trackEvent<UrlPreviewVisibilityChanged>({
410+
eventName: "UrlPreviewVisibilityChanged",
411+
previewKind: "LegacyCard",
412+
hasThumbnail: this.snapshot.current.previews.some((p) => !!p.image),
413+
previewCount: this.snapshot.current.previews.length,
414+
visible: this.urlPreviewEnabledByUser,
415+
});
407416
return this.computeSnapshot();
408417
};
409418

apps/web/test/unit-tests/PosthogAnalytics-test.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/*
2+
Copyright 2026 Element Creations Ltd.
23
Copyright 2024 New Vector Ltd.
34
Copyright 2021 The Matrix.org Foundation C.I.C.
45
@@ -205,6 +206,10 @@ describe("PosthogAnalytics", () => {
205206
analytics.setAnonymity(Anonymity.Pseudonymous);
206207
});
207208

209+
afterEach(() => {
210+
SettingsStore.reset();
211+
});
212+
208213
it("should send layout IRC correctly", async () => {
209214
await SettingsStore.setValue("layout", null, SettingLevel.DEVICE, Layout.IRC);
210215
defaultDispatcher.dispatch(
@@ -217,7 +222,7 @@ describe("PosthogAnalytics", () => {
217222
analytics.trackEvent<ITestEvent>({
218223
eventName: "JestTestEvents",
219224
});
220-
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
225+
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
221226
WebLayout: "IRC",
222227
});
223228
});
@@ -234,7 +239,7 @@ describe("PosthogAnalytics", () => {
234239
analytics.trackEvent<ITestEvent>({
235240
eventName: "JestTestEvents",
236241
});
237-
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
242+
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
238243
WebLayout: "Bubble",
239244
});
240245
});
@@ -251,7 +256,7 @@ describe("PosthogAnalytics", () => {
251256
analytics.trackEvent<ITestEvent>({
252257
eventName: "JestTestEvents",
253258
});
254-
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
259+
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
255260
WebLayout: "Group",
256261
});
257262
});
@@ -269,12 +274,51 @@ describe("PosthogAnalytics", () => {
269274
analytics.trackEvent<ITestEvent>({
270275
eventName: "JestTestEvents",
271276
});
272-
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toStrictEqual({
277+
console.log(mocked(fakePosthog).capture.mock.calls[0]);
278+
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
273279
WebLayout: "Compact",
274280
});
275281
});
276282
});
277283

284+
describe("UrlPreviews", () => {
285+
let analytics: PosthogAnalytics;
286+
287+
beforeEach(() => {
288+
SdkConfig.put({
289+
brand: "Testing",
290+
posthog: {
291+
project_api_key: "foo",
292+
api_host: "bar",
293+
},
294+
});
295+
296+
analytics = new PosthogAnalytics(fakePosthog);
297+
analytics.setAnonymity(Anonymity.Pseudonymous);
298+
});
299+
300+
afterEach(() => {
301+
SdkConfig.reset();
302+
});
303+
304+
it("should set UrlPreviewsEnabled on change", async () => {
305+
defaultDispatcher.dispatch(
306+
{
307+
action: Action.SettingUpdated,
308+
settingName: "urlPreviewsEnabled",
309+
newValue: true,
310+
},
311+
true,
312+
);
313+
analytics.trackEvent<ITestEvent>({
314+
eventName: "JestTestEvents",
315+
});
316+
expect(mocked(fakePosthog).capture.mock.calls[0][1]!["$set"]).toMatchObject({
317+
URLPreviewsEnabled: true,
318+
});
319+
});
320+
});
321+
278322
describe("CryptoSdk", () => {
279323
let analytics: PosthogAnalytics;
280324
const getFakeClient = (): MatrixClient =>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { PosthogAnalytics } from "../../src/PosthogAnalytics";
9+
import PosthogTrackers from "../../src/PosthogTrackers";
10+
11+
describe("PosthogTrackers", () => {
12+
afterEach(() => {
13+
jest.resetAllMocks();
14+
});
15+
16+
it("tracks URL Previews", () => {
17+
jest.spyOn(PosthogAnalytics.instance, "trackEvent");
18+
const tracker = new PosthogTrackers();
19+
tracker.trackUrlPreview("$123456", false, [
20+
{
21+
title: "A preview",
22+
image: {
23+
imageThumb: "abc",
24+
imageFull: "abc",
25+
},
26+
link: "a-link",
27+
},
28+
]);
29+
tracker.trackUrlPreview("$123456", false, [
30+
{
31+
title: "A second preview",
32+
link: "a-link",
33+
},
34+
]);
35+
// Ignores subsequent calls.
36+
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
37+
eventName: "UrlPreviewRendered",
38+
previewKind: "LegacyCard",
39+
hasThumbnail: true,
40+
previewCount: 1,
41+
encryptedRoom: false,
42+
});
43+
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledTimes(1);
44+
});
45+
});

pnpm-lock.yaml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)