diff --git a/src/analytics/PosthogEvents.test.ts b/src/analytics/PosthogEvents.test.ts new file mode 100644 index 00000000..35b86f5d --- /dev/null +++ b/src/analytics/PosthogEvents.test.ts @@ -0,0 +1,162 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { + expect, + describe, + it, + vi, + beforeEach, + beforeAll, + afterAll, +} from "vitest"; +import { logger } from "matrix-js-sdk/lib/logger"; +import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; + +import { PosthogAnalytics } from "./PosthogAnalytics"; +import { CallEndedTracker } from "./PosthogEvents"; +import { mockConfig } from "../utils/test"; + +const defaultCounters = { + roomEventEncryptionKeysSent: 10, + roomEventEncryptionKeysReceived: 5, +}; + +const defaultTotals = { + roomEventEncryptionKeysReceivedTotalAge: 500, +}; + +function createMockRtcSession(overrides?: { + counters?: Partial; + totals?: Partial; +}): MatrixRTCSession { + return { + statistics: { + counters: { ...defaultCounters, ...overrides?.counters }, + totals: { ...defaultTotals, ...overrides?.totals }, + }, + } as unknown as MatrixRTCSession; +} + +describe("CallEnded", () => { + beforeAll(() => { + mockConfig(); + }); + + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(PosthogAnalytics.instance, "trackEvent").mockImplementation( + () => {}, + ); + }); + + afterAll(() => { + PosthogAnalytics.resetInstance(); + }); + + it("warns if startTime is missing when track is called", () => { + const warnSpy = vi.spyOn(logger, "warn"); + const tracker = new CallEndedTracker(); + const mockSession = createMockRtcSession(); + + tracker.track("test-call-id", 2, false, mockSession); + + expect(warnSpy).toHaveBeenCalledWith( + "[PosthogEvents] Failed to send posthog callEnded event due to missing startTime", + ); + expect(PosthogAnalytics.instance.trackEvent).not.toHaveBeenCalled(); + }); + + it("tracks event with correct properties when startTime is set", () => { + const tracker = new CallEndedTracker(); + const mockSession = createMockRtcSession(); + + tracker.cacheStartCall(new Date(Date.now() - 60000)); + tracker.cacheParticipantCountChanged(5); + tracker.track("test-call-id", 3, true, mockSession); + + expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith( + { + eventName: "CallEnded", + callId: "test-call-id", + callParticipantsMax: 5, + callParticipantsOnLeave: 3, + callDuration: expect.closeTo(60, 1), + roomEventEncryptionKeysSent: 10, + roomEventEncryptionKeysReceived: 5, + roomEventEncryptionKeysReceivedAverageAge: 100, + }, + { send_instantly: true }, + ); + }); + + it("tracks maxParticipantsCount correctly across multiple changes", () => { + const tracker = new CallEndedTracker(); + const mockSession = createMockRtcSession(); + + tracker.cacheStartCall(new Date()); + tracker.cacheParticipantCountChanged(3); + tracker.cacheParticipantCountChanged(7); + tracker.cacheParticipantCountChanged(2); + tracker.track("test-call-id", 1, false, mockSession); + + expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + callParticipantsMax: 7, + }), + expect.anything(), + ); + }); + + it("computes roomEventEncryptionKeysReceivedAverageAge as 0 when no keys received", () => { + const tracker = new CallEndedTracker(); + const mockSession = createMockRtcSession({ + counters: { roomEventEncryptionKeysReceived: 0 }, + }); + + tracker.cacheStartCall(new Date()); + tracker.track("test-call-id", 1, false, mockSession); + + expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + roomEventEncryptionKeysReceivedAverageAge: 0, + }), + expect.anything(), + ); + }); + + it("computes roomEventEncryptionKeysReceivedAverageAge correctly when keys are received", () => { + const tracker = new CallEndedTracker(); + const mockSession = createMockRtcSession({ + counters: { roomEventEncryptionKeysReceived: 4 }, + totals: { roomEventEncryptionKeysReceivedTotalAge: 200 }, + }); + + tracker.cacheStartCall(new Date()); + tracker.track("test-call-id", 1, false, mockSession); + + expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + roomEventEncryptionKeysReceivedAverageAge: 50, + }), + expect.anything(), + ); + }); + + it("passes send_instantly option correctly", () => { + const tracker = new CallEndedTracker(); + const mockSession = createMockRtcSession(); + + tracker.cacheStartCall(new Date()); + tracker.track("test-call-id", 1, false, mockSession); + + expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith( + expect.anything(), + { send_instantly: false }, + ); + }); +}); diff --git a/src/analytics/PosthogEvents.ts b/src/analytics/PosthogEvents.ts index f0f059f5..5553829a 100644 --- a/src/analytics/PosthogEvents.ts +++ b/src/analytics/PosthogEvents.ts @@ -27,8 +27,8 @@ interface CallEnded extends IPosthogEvent { } export class CallEndedTracker { - private cache: { startTime: Date; maxParticipantsCount: number } = { - startTime: new Date(0), + private cache: { startTime?: Date; maxParticipantsCount: number } = { + startTime: undefined, maxParticipantsCount: 0, }; @@ -49,26 +49,32 @@ export class CallEndedTracker { sendInstantly: boolean, rtcSession: MatrixRTCSession, ): void { - PosthogAnalytics.instance.trackEvent( - { - eventName: "CallEnded", - callId: callId, - callParticipantsMax: this.cache.maxParticipantsCount, - callParticipantsOnLeave: callParticipantsNow, - callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000, - roomEventEncryptionKeysSent: - rtcSession.statistics.counters.roomEventEncryptionKeysSent, - roomEventEncryptionKeysReceived: - rtcSession.statistics.counters.roomEventEncryptionKeysReceived, - roomEventEncryptionKeysReceivedAverageAge: - rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0 - ? rtcSession.statistics.totals - .roomEventEncryptionKeysReceivedTotalAge / - rtcSession.statistics.counters.roomEventEncryptionKeysReceived - : 0, - }, - { send_instantly: sendInstantly }, - ); + if (this.cache.startTime) { + PosthogAnalytics.instance.trackEvent( + { + eventName: "CallEnded", + callId: callId, + callParticipantsMax: this.cache.maxParticipantsCount, + callParticipantsOnLeave: callParticipantsNow, + callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000, + roomEventEncryptionKeysSent: + rtcSession.statistics.counters.roomEventEncryptionKeysSent, + roomEventEncryptionKeysReceived: + rtcSession.statistics.counters.roomEventEncryptionKeysReceived, + roomEventEncryptionKeysReceivedAverageAge: + rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0 + ? rtcSession.statistics.totals + .roomEventEncryptionKeysReceivedTotalAge / + rtcSession.statistics.counters.roomEventEncryptionKeysReceived + : 0, + }, + { send_instantly: sendInstantly }, + ); + } else { + logger.warn( + "[PosthogEvents] Failed to send posthog callEnded event due to missing startTime", + ); + } } }