From ea7684c9014f5e505833526736db94f894d16bde Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 17 Sep 2025 14:26:40 +0200 Subject: [PATCH 1/8] feat: Participant Stats --- packages/client/src/Call.ts | 15 ++++++ .../react-dogfood/components/DevMenu.tsx | 10 ++++ .../react/react-dogfood/pages/stats/[cid].tsx | 49 +++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 sample-apps/react/react-dogfood/pages/stats/[cid].tsx diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts index ae3ba68569..68b90c70b8 100644 --- a/packages/client/src/Call.ts +++ b/packages/client/src/Call.ts @@ -2553,6 +2553,21 @@ export class Call { return this.streamClient.get(endpoint, params); }; + /** + * Loads the call report for the given session ID. + * + * @param sessionId optional session ID to load the report for. + * Defaults to the current session ID. + */ + getCallParticipantsStats = async ( + sessionId: string | undefined = this.state.session?.id, + ): Promise => { + // FIXME OL: not yet part of the API + if (!sessionId) return; + const endpoint = `https://video-edge-frankfurt-ce1.stream-io-api.com/video/call_stats/${this.type}/${this.id}/${sessionId}/participants`; + return this.streamClient.get(endpoint); + }; + /** * Submit user feedback for the call * diff --git a/sample-apps/react/react-dogfood/components/DevMenu.tsx b/sample-apps/react/react-dogfood/components/DevMenu.tsx index 0c954288db..8bc4328f0d 100644 --- a/sample-apps/react/react-dogfood/components/DevMenu.tsx +++ b/sample-apps/react/react-dogfood/components/DevMenu.tsx @@ -97,6 +97,16 @@ export const DevMenu = () => { Go to Inspector )} + {call && ( + + Go to Participant Stats + + )} ); }; diff --git a/sample-apps/react/react-dogfood/pages/stats/[cid].tsx b/sample-apps/react/react-dogfood/pages/stats/[cid].tsx new file mode 100644 index 0000000000..68a13a4b9b --- /dev/null +++ b/sample-apps/react/react-dogfood/pages/stats/[cid].tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { + getServerSideCredentialsProps, + ServerSideCredentialsProps, +} from '../../lib/getServerSideCredentialsProps'; +import { useAppEnvironment } from '../../context/AppEnvironmentContext'; +import { getClient } from '../../helpers/client'; + +export default function Stats(props: ServerSideCredentialsProps) { + const { apiKey, userToken, user } = props; + const environment = useAppEnvironment(); + const router = useRouter(); + const cid = router.query['cid'] as string; + const [data, setData] = useState({ message: 'Loading...' }); + + useEffect(() => { + const _client = getClient({ apiKey, user, userToken }, environment); + window.client = _client; + + const [type, id] = cid.split(':'); + const _call = _client.call(type, id, { reuseInstance: true }); + (async () => { + try { + await _call.get(); + const stats = await _call.getCallParticipantsStats(); + console.log('Call participants stats:', stats); + setData(stats); + } catch (err) { + setData({ message: 'Failed to get call participants stats', err }); + } + })(); + + return () => { + _client + .disconnectUser() + .catch((e) => console.error('Failed to disconnect user', e)); + + window.client = undefined; + }; + }, [apiKey, user, userToken, environment, cid]); + + return ( +
+      {data && JSON.stringify(data, null, 2)}
+    
+ ); +} +export const getServerSideProps = getServerSideCredentialsProps; From b914e251de6ec9912b2901a5c0bf82b1e2eeea2a Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 23 Sep 2025 20:36:41 +0200 Subject: [PATCH 2/8] chore: connect to local coordinator --- .../react/react-dogfood/pages/stats/[cid].tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/sample-apps/react/react-dogfood/pages/stats/[cid].tsx b/sample-apps/react/react-dogfood/pages/stats/[cid].tsx index 68a13a4b9b..d541cc96a2 100644 --- a/sample-apps/react/react-dogfood/pages/stats/[cid].tsx +++ b/sample-apps/react/react-dogfood/pages/stats/[cid].tsx @@ -15,7 +15,15 @@ export default function Stats(props: ServerSideCredentialsProps) { const [data, setData] = useState({ message: 'Loading...' }); useEffect(() => { - const _client = getClient({ apiKey, user, userToken }, environment); + const useLocalCoordinator = + router.query['use_local_coordinator'] === 'true'; + const coordinatorUrl = useLocalCoordinator + ? 'http://localhost:3030/video' + : (router.query['coordinator_url'] as string | undefined); + const _client = getClient( + { apiKey, user, userToken, coordinatorUrl }, + environment, + ); window.client = _client; const [type, id] = cid.split(':'); @@ -38,7 +46,7 @@ export default function Stats(props: ServerSideCredentialsProps) { window.client = undefined; }; - }, [apiKey, user, userToken, environment, cid]); + }, [apiKey, user, userToken, environment, cid, router.query]); return (

From 65fea50efd98c8695b42d8f69dd9b627dd61cae4 Mon Sep 17 00:00:00 2001
From: Oliver Lazoroski 
Date: Wed, 24 Sep 2025 10:43:43 +0200
Subject: [PATCH 3/8] fix: send unifiedSessionId in the initial join request

---
 packages/client/src/Call.ts                       |  8 ++++++--
 packages/client/src/gen/video/sfu/event/events.ts | 14 ++++++++++++++
 2 files changed, 20 insertions(+), 2 deletions(-)

diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts
index 68b90c70b8..abed6c6002 100644
--- a/packages/client/src/Call.ts
+++ b/packages/client/src/Call.ts
@@ -981,6 +981,7 @@ export class Call {
           })
         : previousSfuClient;
     this.sfuClient = sfuClient;
+    this.unifiedSessionId ??= sfuClient.sessionId;
     this.dynascaleManager.setSfuClient(sfuClient);
 
     const clientDetails = await getClientDetails();
@@ -1008,6 +1009,7 @@ export class Call {
       try {
         const { callState, fastReconnectDeadlineSeconds, publishOptions } =
           await sfuClient.join({
+            unifiedSessionId: this.unifiedSessionId,
             subscriberSdp,
             publisherSdp,
             clientDetails,
@@ -1061,6 +1063,7 @@ export class Call {
         statsOptions,
         publishOptions: this.currentPublishOptions || [],
         closePreviousInstances: !performingMigration,
+        unifiedSessionId: this.unifiedSessionId,
       });
     }
 
@@ -1222,6 +1225,7 @@ export class Call {
     clientDetails: ClientDetails;
     publishOptions: PublishOption[];
     closePreviousInstances: boolean;
+    unifiedSessionId: string;
   }) => {
     const {
       sfuClient,
@@ -1230,6 +1234,7 @@ export class Call {
       statsOptions,
       publishOptions,
       closePreviousInstances,
+      unifiedSessionId,
     } = opts;
     const { enable_rtc_stats: enableTracing } = statsOptions;
     if (closePreviousInstances && this.subscriber) {
@@ -1288,7 +1293,6 @@ export class Call {
     this.tracer.setEnabled(enableTracing);
     this.sfuStatsReporter?.stop();
     if (statsOptions?.reporting_interval_ms > 0) {
-      this.unifiedSessionId ??= sfuClient.sessionId;
       this.sfuStatsReporter = new SfuStatsReporter(sfuClient, {
         clientDetails,
         options: statsOptions,
@@ -1298,7 +1302,7 @@ export class Call {
         camera: this.camera,
         state: this.state,
         tracer: this.tracer,
-        unifiedSessionId: this.unifiedSessionId,
+        unifiedSessionId,
       });
       this.sfuStatsReporter.start();
     }
diff --git a/packages/client/src/gen/video/sfu/event/events.ts b/packages/client/src/gen/video/sfu/event/events.ts
index cafec82f7b..12d102d5cd 100644
--- a/packages/client/src/gen/video/sfu/event/events.ts
+++ b/packages/client/src/gen/video/sfu/event/events.ts
@@ -471,6 +471,14 @@ export interface JoinRequest {
    * @generated from protobuf field: string session_id = 2;
    */
   sessionId: string;
+  /**
+   * user_session id can change during reconnects, this helps us to
+   * identify the user across reconnects and should remain consistent until the user explicitly
+   * disconnects, is kicked or the call is ended.
+   *
+   * @generated from protobuf field: string unified_session_id = 13;
+   */
+  unifiedSessionId: string;
   /**
    * dumb SDP that allow us to extract subscriber's decode codecs
    *
@@ -1353,6 +1361,12 @@ class JoinRequest$Type extends MessageType {
     super('stream.video.sfu.event.JoinRequest', [
       { no: 1, name: 'token', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
       { no: 2, name: 'session_id', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
+      {
+        no: 13,
+        name: 'unified_session_id',
+        kind: 'scalar',
+        T: 9 /*ScalarType.STRING*/,
+      },
       {
         no: 3,
         name: 'subscriber_sdp',

From aff95004dd226290226840fcb0cbdbbc0df53939 Mon Sep 17 00:00:00 2001
From: Oliver Lazoroski 
Date: Mon, 29 Sep 2025 13:49:15 +0200
Subject: [PATCH 4/8] adjust

---
 packages/client/src/Call.ts                   | 21 ++++++++++++-------
 .../react/react-dogfood/pages/stats/[cid].tsx | 10 ++++++++-
 2 files changed, 23 insertions(+), 8 deletions(-)

diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts
index ff959d3815..92e4ba829a 100644
--- a/packages/client/src/Call.ts
+++ b/packages/client/src/Call.ts
@@ -2555,16 +2555,23 @@ export class Call {
 
   /**
    * Loads the call report for the given session ID.
-   *
-   * @param sessionId optional session ID to load the report for.
-   * Defaults to the current session ID.
    */
-  getCallParticipantsStats = async (
-    sessionId: string | undefined = this.state.session?.id,
-  ): Promise => {
+  getCallParticipantsStats = async (opts: {
+    sessionId?: string;
+    userId?: string;
+    userSessionId?: string;
+  }): Promise => {
+    const {
+      sessionId = this.state.session?.id,
+      userId = this.currentUserId,
+      userSessionId = this.unifiedSessionId,
+    } = opts;
     // FIXME OL: not yet part of the API
     if (!sessionId) return;
-    const endpoint = `https://video-edge-frankfurt-ce1.stream-io-api.com/video/call_stats/${this.type}/${this.id}/${sessionId}/participants`;
+    const endpoint =
+      userId && userSessionId
+        ? `https://video-edge-frankfurt-ce1.stream-io-api.com/video/call_stats/${this.type}/${this.id}/${sessionId}/participant/${userId}/${userSessionId}/details`
+        : `https://video-edge-frankfurt-ce1.stream-io-api.com/video/call_stats/${this.type}/${this.id}/${sessionId}/participants`;
     return this.streamClient.get(endpoint);
   };
 
diff --git a/sample-apps/react/react-dogfood/pages/stats/[cid].tsx b/sample-apps/react/react-dogfood/pages/stats/[cid].tsx
index d541cc96a2..ca1f2f6001 100644
--- a/sample-apps/react/react-dogfood/pages/stats/[cid].tsx
+++ b/sample-apps/react/react-dogfood/pages/stats/[cid].tsx
@@ -31,7 +31,15 @@ export default function Stats(props: ServerSideCredentialsProps) {
     (async () => {
       try {
         await _call.get();
-        const stats = await _call.getCallParticipantsStats();
+        const userId =
+          (router.query['user_id'] as string | undefined) || user.id;
+        const userSessionId = router.query['user_session_id'] as
+          | string
+          | undefined;
+        const stats = await _call.getCallParticipantsStats({
+          userId,
+          userSessionId,
+        });
         console.log('Call participants stats:', stats);
         setData(stats);
       } catch (err) {

From 2196bf8df80c8caff826cd074c6a462458443b6d Mon Sep 17 00:00:00 2001
From: Oliver Lazoroski 
Date: Mon, 29 Sep 2025 15:48:43 +0200
Subject: [PATCH 5/8] add participant stats

---
 .../react/react-dogfood/components/DevMenu.tsx       | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/sample-apps/react/react-dogfood/components/DevMenu.tsx b/sample-apps/react/react-dogfood/components/DevMenu.tsx
index 8bc4328f0d..4a5a3326c8 100644
--- a/sample-apps/react/react-dogfood/components/DevMenu.tsx
+++ b/sample-apps/react/react-dogfood/components/DevMenu.tsx
@@ -4,6 +4,8 @@ import { getConnectionString } from '../lib/connectionString';
 
 export const DevMenu = () => {
   const call = useCall();
+  const { useLocalParticipant } = useCallStateHooks();
+  const localParticipant = useLocalParticipant();
   return (
     
   );
 };

From 903953a8bb0a266b055ab8be7fc36cf199409389 Mon Sep 17 00:00:00 2001
From: Oliver Lazoroski 
Date: Mon, 29 Sep 2025 23:05:20 +0200
Subject: [PATCH 6/8] remove extra bracket

---
 sample-apps/react/react-dogfood/components/DevMenu.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/sample-apps/react/react-dogfood/components/DevMenu.tsx b/sample-apps/react/react-dogfood/components/DevMenu.tsx
index 4a5a3326c8..77440d6fe8 100644
--- a/sample-apps/react/react-dogfood/components/DevMenu.tsx
+++ b/sample-apps/react/react-dogfood/components/DevMenu.tsx
@@ -112,7 +112,7 @@ export const DevMenu = () => {
       {call && localParticipant && (
         

From a88c9e52a9ece2e333c89f4d4675b4d64ff5fd17 Mon Sep 17 00:00:00 2001
From: Oliver Lazoroski 
Date: Thu, 2 Oct 2025 17:01:37 +0200
Subject: [PATCH 7/8] add timeline

---
 packages/client/src/Call.ts                        | 12 +++++++-----
 .../react/react-dogfood/components/DevMenu.tsx     | 14 ++++++++++++--
 .../react/react-dogfood/pages/stats/[cid].tsx      |  3 +++
 3 files changed, 22 insertions(+), 7 deletions(-)

diff --git a/packages/client/src/Call.ts b/packages/client/src/Call.ts
index db04d84e0b..5d97a66699 100644
--- a/packages/client/src/Call.ts
+++ b/packages/client/src/Call.ts
@@ -2237,13 +2237,10 @@ export class Call {
 
   /**
    * Allows you to grant or revoke a specific permission to a user in a call. The permissions are specific to the call experience and do not survive the call itself.
-   *
    * When revoking a permission, this endpoint will also mute the relevant track from the user. This is similar to muting a user with the difference that the user will not be able to unmute afterwards.
-   *
    * Supported permissions that can be granted or revoked: `send-audio`, `send-video` and `screenshare`.
    *
    * `call.permissions_updated` event is sent to all members of the call.
-   *
    */
   updateUserPermissions = async (data: UpdateUserPermissionsRequest) => {
     return this.streamClient.post<
@@ -2567,18 +2564,23 @@ export class Call {
     sessionId?: string;
     userId?: string;
     userSessionId?: string;
+    kind?: 'timeline' | 'details';
   }): Promise => {
     const {
       sessionId = this.state.session?.id,
       userId = this.currentUserId,
       userSessionId = this.unifiedSessionId,
+      kind = 'details',
     } = opts;
     // FIXME OL: not yet part of the API
     if (!sessionId) return;
+    const base = `${this.streamClient.baseURL}/call_stats/${this.type}/${this.id}/${sessionId}`;
     const endpoint =
       userId && userSessionId
-        ? `https://video-edge-frankfurt-ce1.stream-io-api.com/video/call_stats/${this.type}/${this.id}/${sessionId}/participant/${userId}/${userSessionId}/details`
-        : `https://video-edge-frankfurt-ce1.stream-io-api.com/video/call_stats/${this.type}/${this.id}/${sessionId}/participants`;
+        ? kind === 'details'
+          ? `${base}/participant/${userId}/${userSessionId}/details`
+          : `${base}/participants/${userId}/${userSessionId}/timeline`
+        : `${base}/participants`;
     return this.streamClient.get(endpoint);
   };
 
diff --git a/sample-apps/react/react-dogfood/components/DevMenu.tsx b/sample-apps/react/react-dogfood/components/DevMenu.tsx
index 77440d6fe8..2b4f257796 100644
--- a/sample-apps/react/react-dogfood/components/DevMenu.tsx
+++ b/sample-apps/react/react-dogfood/components/DevMenu.tsx
@@ -112,11 +112,21 @@ export const DevMenu = () => {
       {call && localParticipant && (
         
-          Go to {localParticipant?.name || 'User'} Stats
+          Go to {localParticipant?.name || 'User'} Stats (Details)
+        
+      )}
+      {call && localParticipant && (
+        
+          Go to {localParticipant?.name || 'User'} Stats (Timeline)
         
       )}
     
diff --git a/sample-apps/react/react-dogfood/pages/stats/[cid].tsx b/sample-apps/react/react-dogfood/pages/stats/[cid].tsx
index ca1f2f6001..e5af6168f1 100644
--- a/sample-apps/react/react-dogfood/pages/stats/[cid].tsx
+++ b/sample-apps/react/react-dogfood/pages/stats/[cid].tsx
@@ -36,9 +36,12 @@ export default function Stats(props: ServerSideCredentialsProps) {
         const userSessionId = router.query['user_session_id'] as
           | string
           | undefined;
+        const kind =
+          (router.query['kind'] as 'details' | 'timeline') || 'details';
         const stats = await _call.getCallParticipantsStats({
           userId,
           userSessionId,
+          kind,
         });
         console.log('Call participants stats:', stats);
         setData(stats);

From ed3dc3774bcb545aa05b9e47d35264bbb1c8480e Mon Sep 17 00:00:00 2001
From: Oliver Lazoroski 
Date: Thu, 2 Oct 2025 18:18:52 +0200
Subject: [PATCH 8/8] add call_session_id

---
 sample-apps/react/react-dogfood/pages/stats/[cid].tsx | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/sample-apps/react/react-dogfood/pages/stats/[cid].tsx b/sample-apps/react/react-dogfood/pages/stats/[cid].tsx
index e5af6168f1..5d710b3816 100644
--- a/sample-apps/react/react-dogfood/pages/stats/[cid].tsx
+++ b/sample-apps/react/react-dogfood/pages/stats/[cid].tsx
@@ -20,6 +20,7 @@ export default function Stats(props: ServerSideCredentialsProps) {
     const coordinatorUrl = useLocalCoordinator
       ? 'http://localhost:3030/video'
       : (router.query['coordinator_url'] as string | undefined);
+    const callSessionId = router.query['call_session_id'] as string | undefined;
     const _client = getClient(
       { apiKey, user, userToken, coordinatorUrl },
       environment,
@@ -41,6 +42,7 @@ export default function Stats(props: ServerSideCredentialsProps) {
         const stats = await _call.getCallParticipantsStats({
           userId,
           userSessionId,
+          sessionId: callSessionId,
           kind,
         });
         console.log('Call participants stats:', stats);