Track reconnect duration and align with existing analytics pattern

This commit is contained in:
fkwp
2026-05-08 12:06:45 +02:00
parent e3231317c5
commit 4ffe3a6d2a
5 changed files with 57 additions and 42 deletions

View File

@@ -27,7 +27,6 @@ import {
CallDisconnectedEventTracker, CallDisconnectedEventTracker,
CallConnectDurationTracker, CallConnectDurationTracker,
CallReconnectingTracker, CallReconnectingTracker,
type CallReconnectingReason,
} from "./PosthogEvents"; } from "./PosthogEvents";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams"; import { getUrlParams } from "../UrlParams";
@@ -423,13 +422,5 @@ export class PosthogAnalytics {
public eventQualitySurvey = new QualitySurveyEventTracker(); public eventQualitySurvey = new QualitySurveyEventTracker();
public eventCallDisconnected = new CallDisconnectedEventTracker(); public eventCallDisconnected = new CallDisconnectedEventTracker();
public eventCallConnectDuration = new CallConnectDurationTracker(); public eventCallConnectDuration = new CallConnectDurationTracker();
private readonly _eventCallReconnecting = new CallReconnectingTracker(); public eventCallReconnecting = new CallReconnectingTracker();
public trackCallReconnecting(
callId: string,
reason: CallReconnectingReason,
): void {
this._eventCallReconnecting.track(callId, reason);
this.eventCallEnded.cacheReconnecting(reason);
}
} }

View File

@@ -211,12 +211,13 @@ describe("CallReconnecting", () => {
it("tracks event with correct shape", () => { it("tracks event with correct shape", () => {
const tracker = new CallReconnectingTracker(); const tracker = new CallReconnectingTracker();
tracker.track("!room:example.org", "syncing"); tracker.track("!room:example.org", "syncing", 3.5);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({ expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
eventName: "CallReconnecting", eventName: "CallReconnecting",
callId: "!room:example.org", callId: "!room:example.org",
reason: "syncing", reason: "syncing",
reconnectDuration: 3.5,
}); });
}); });
@@ -227,10 +228,10 @@ describe("CallReconnecting", () => {
"livekit", "livekit",
] as CallReconnectingReason[])("tracks reason %s correctly", (reason) => { ] as CallReconnectingReason[])("tracks reason %s correctly", (reason) => {
const tracker = new CallReconnectingTracker(); const tracker = new CallReconnectingTracker();
tracker.track("!room:example.org", reason); tracker.track("!room:example.org", reason, 1.0);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith( expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({ reason }), expect.objectContaining({ reason, reconnectDuration: 1.0 }),
); );
}); });
}); });

View File

@@ -291,14 +291,20 @@ interface CallReconnecting extends IPosthogEvent {
eventName: "CallReconnecting"; eventName: "CallReconnecting";
callId: string; callId: string;
reason: CallReconnectingReason; reason: CallReconnectingReason;
reconnectDuration: number;
} }
export class CallReconnectingTracker { export class CallReconnectingTracker {
public track(callId: string, reason: CallReconnectingReason): void { public track(
callId: string,
reason: CallReconnectingReason,
reconnectDuration: number,
): void {
PosthogAnalytics.instance.trackEvent<CallReconnecting>({ PosthogAnalytics.instance.trackEvent<CallReconnecting>({
eventName: "CallReconnecting", eventName: "CallReconnecting",
callId, callId,
reason, reason,
reconnectDuration,
}); });
} }
} }

View File

@@ -693,11 +693,11 @@ describe("LocalMembership", () => {
PosthogAnalytics.resetInstance(); PosthogAnalytics.resetInstance();
}); });
it("fires CallReconnecting with the homeserver reason when homeserver disconnects", async () => { it("fires CallReconnecting with homeserver reason and duration when reconnected", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const trackSpy = vi.spyOn( const trackSpy = vi.spyOn(
PosthogAnalytics.instance, PosthogAnalytics.instance.eventCallReconnecting,
"trackCallReconnecting", "track",
); );
const hsConnected$ = new BehaviorSubject<boolean>(true); const hsConnected$ = new BehaviorSubject<boolean>(true);
@@ -726,23 +726,26 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
// Simulate homeserver disconnect with syncing as the reason // Disconnect with syncing reason, then reconnect
disconnectReason$.next("syncing"); disconnectReason$.next("syncing");
hsConnected$.next(false); hsConnected$.next(false);
disconnectReason$.next(null);
hsConnected$.next(true);
expect(trackSpy).toHaveBeenCalledWith( expect(trackSpy).toHaveBeenCalledWith(
defaultCreateLocalMemberValues.callId, defaultCreateLocalMemberValues.callId,
"syncing", "syncing",
expect.any(Number),
); );
scope.end(); scope.end();
}); });
it("reports livekit reason when livekit disconnects but homeserver is fine", async () => { it("reports livekit reason when livekit disconnects then reconnects", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const trackSpy = vi.spyOn( const trackSpy = vi.spyOn(
PosthogAnalytics.instance, PosthogAnalytics.instance.eventCallReconnecting,
"trackCallReconnecting", "track",
); );
const connectionState$ = new BehaviorSubject<ConnectionState>( const connectionState$ = new BehaviorSubject<ConnectionState>(
@@ -776,22 +779,24 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
// Livekit drops while homeserver stays connected // Livekit drops then recovers
connectionState$.next(ConnectionState.LivekitDisconnected); connectionState$.next(ConnectionState.LivekitDisconnected);
connectionState$.next(ConnectionState.LivekitConnected);
expect(trackSpy).toHaveBeenCalledWith( expect(trackSpy).toHaveBeenCalledWith(
defaultCreateLocalMemberValues.callId, defaultCreateLocalMemberValues.callId,
"livekit", "livekit",
expect.any(Number),
); );
scope.end(); scope.end();
}); });
it("fires one event per reconnection, not once per condition change", async () => { it("fires one event per completed reconnection cycle", async () => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const trackSpy = vi.spyOn( const trackSpy = vi.spyOn(
PosthogAnalytics.instance, PosthogAnalytics.instance.eventCallReconnecting,
"trackCallReconnecting", "track",
); );
const hsConnected$ = new BehaviorSubject<boolean>(true); const hsConnected$ = new BehaviorSubject<boolean>(true);
@@ -820,26 +825,29 @@ describe("LocalMembership", () => {
await flushPromises(); await flushPromises();
// First reconnect // First full reconnect cycle
disconnectReason$.next("membershipConnected"); disconnectReason$.next("membershipConnected");
hsConnected$.next(false); hsConnected$.next(false);
// Reconnected
disconnectReason$.next(null); disconnectReason$.next(null);
hsConnected$.next(true); hsConnected$.next(true);
// Second reconnect // Second full reconnect cycle
disconnectReason$.next("certainlyConnected"); disconnectReason$.next("certainlyConnected");
hsConnected$.next(false); hsConnected$.next(false);
disconnectReason$.next(null);
hsConnected$.next(true);
expect(trackSpy).toHaveBeenCalledTimes(2); expect(trackSpy).toHaveBeenCalledTimes(2);
expect(trackSpy).toHaveBeenNthCalledWith( expect(trackSpy).toHaveBeenNthCalledWith(
1, 1,
defaultCreateLocalMemberValues.callId, defaultCreateLocalMemberValues.callId,
"membershipConnected", "membershipConnected",
expect.any(Number),
); );
expect(trackSpy).toHaveBeenNthCalledWith( expect(trackSpy).toHaveBeenNthCalledWith(
2, 2,
defaultCreateLocalMemberValues.callId, defaultCreateLocalMemberValues.callId,
"certainlyConnected", "certainlyConnected",
expect.any(Number),
); );
scope.end(); scope.end();

View File

@@ -25,7 +25,6 @@ import {
catchError, catchError,
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
filter,
from, from,
fromEvent, fromEvent,
map, map,
@@ -35,7 +34,6 @@ import {
startWith, startWith,
switchMap, switchMap,
tap, tap,
withLatestFrom,
} from "rxjs"; } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { deepCompare } from "matrix-js-sdk/lib/utils"; import { deepCompare } from "matrix-js-sdk/lib/utils";
@@ -55,6 +53,7 @@ import {
import { ElementWidgetActions, widget } from "../../../widget.ts"; import { ElementWidgetActions, widget } from "../../../widget.ts";
import { getUrlParams } from "../../../UrlParams.ts"; import { getUrlParams } from "../../../UrlParams.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts"; import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import { type CallReconnectingReason } from "../../../analytics/PosthogEvents.ts";
import { MatrixRTCMode } from "../../../settings/settings.ts"; import { MatrixRTCMode } from "../../../settings/settings.ts";
import { Config } from "../../../config/Config.ts"; import { Config } from "../../../config/Config.ts";
import { import {
@@ -524,19 +523,29 @@ export const createLocalMembership$ = ({
false, false,
); );
let reconnectStart: { time: number; reason: CallReconnectingReason } | null =
null;
reconnecting$ reconnecting$
.pipe( .pipe(distinctUntilChanged(), scope.bind())
distinctUntilChanged(), .subscribe((reconnecting) => {
filter(Boolean), if (reconnecting) {
withLatestFrom( const homeserverReason = homeserverConnected.disconnectReason$.value;
homeserverConnected.disconnectReason$, reconnectStart = {
localConnectionState$, time: Date.now(),
), reason: homeserverReason !== null ? homeserverReason : "livekit",
scope.bind(), };
) } else if (reconnectStart !== null) {
.subscribe(([_, homeserverReason]) => { const duration = (Date.now() - reconnectStart.time) / 1000;
const reason = homeserverReason !== null ? homeserverReason : "livekit"; PosthogAnalytics.instance.eventCallReconnecting.track(
PosthogAnalytics.instance.trackCallReconnecting(callId, reason); callId,
reconnectStart.reason,
duration,
);
PosthogAnalytics.instance.eventCallEnded.cacheReconnecting(
reconnectStart.reason,
);
reconnectStart = null;
}
}); });
// inform the widget about the connect and disconnect intent from the user. // inform the widget about the connect and disconnect intent from the user.