Vonage Video
Add rtcstats-js to a Vonage Video (VERA) React app by wrapping the WebRTC globals before the SDK loads and opening the connection to rtcstats-server when a session starts.
The Vonage Video API (formerly OpenTok) is a managed platform for building video calling into web and mobile apps. Its JavaScript SDK (@vonage/client-sdk-video) creates its peer connections through the browser's global RTCPeerConnection, which is exactly what rtcstats-js monkey-patches. Patch once at startup, before the SDK loads, and every session Vonage creates gets traced automatically.
This guide follows the Vonage Video React App (VERA) reference app, but the pattern works in any app built on the Vonage Video SDK.
Ordering is the whole game
The one thing that makes a Vonage integration different from a plain LiveKit or Jitsi one is when you wrap the globals. VERA runs a set of interceptors at boot that clone navigator.mediaDevices (to instrument getUserMedia, XHR, and friends). If rtcstats-js wraps getUserMedia after that clone happens, it patches a copy the SDK never uses, and you get no media data.
So rtcstats-js has to wrap first, before the interceptors and before the SDK. The connection to rtcstats-server, on the other hand, happens later, once a session exists and the backend has handed the client a connection URL for it. Splitting those two moments apart is the core of the integration.
Before you start
You need:
- A running
rtcstats-server(local or deployed) - A Vonage Video app you can build (VERA works out of the box)
- A backend you control (VERA ships an Express backend) so you can mint the rtcstats token server-side
Step 1: install rtcstats-js
The Vonage app does not depend on rtcstats-js, so add it yourself from the root of your checkout:
npm install @rtcstats/rtcstats-js
Step 2: wrap the globals in a singleton module
Create a tiny module that patches the WebRTC globals once and exports the resulting trace. Keeping it in its own module means you can import it from both the app entry point (to run the patch early) and the video client (to open the connection later).
// frontend/src/rtcStatsTrace.ts
// @ts-expect-error - @rtcstats/rtcstats-js ships no type declarations
import { wrapRTCStatsWithDefaultOptions } from '@rtcstats/rtcstats-js';
// Patches the global RTCPeerConnection/getUserMedia so every peer connection the Vonage
// SDK creates is traced. Import this first in main.tsx so it runs before the SDK loads;
// call .connect(wsUrl) later (when a session starts) to open the rtcstats-server socket.
const rtcStatsTrace = wrapRTCStatsWithDefaultOptions();
export default rtcStatsTrace;
wrapRTCStatsWithDefaultOptions() monkey-patches the global RTCPeerConnection, getUserMedia, getDisplayMedia, and enumerateDevices. The @ts-expect-error is there because the package ships no type declarations; that one line keeps TypeScript happy without a .d.ts shim.
Step 3: import it first, before the interceptors
In your app entry point, import the trace module as the very first import, ahead of the interceptors and the SDK:
// frontend/src/main.tsx
// wrap RTCPeerConnection/getUserMedia for rtcstats before the interceptors clone
// navigator.mediaDevices and before the vonage sdk creates any peer connections
import './rtcStatsTrace';
// runs interceptors before vonage sdk initializes resources
import '@core/interceptors';
// ... the rest of your bootstrap
Because module imports run top to bottom, putting ./rtcStatsTrace first guarantees the patched globals are the ones the interceptors clone and the SDK later uses.
Step 4: co-generate the connection URL with the Vonage token
The globals are patched, but nothing is streaming yet. The client needs a signed WebSocket URL to open the trace, and the JWT secret has to stay on the server, so the URL is minted on the backend. The clean way to do that is to mint it in the same place VERA already mints the Vonage token: the joinSession handler. That way the client gets both from a single call, and there is no second endpoint and no extra round trip.
VERA's video handler exposes an onSettled$ hook that runs right after an action resolves. Hook joinSession, mint the rtcstats token from the session the handler just decoded, and attach the URL to the result:
// backend/routes/video/setup.ts
import jwt from 'jsonwebtoken';
import { VideoSessionDetailsWithToken } from '@common/types';
import { videoHandler } from './video';
// Runs after the Vonage token is minted, so it never changes how that token is created.
// The claims come from the decoded session (never from client input), so they always match
// the real session. The client passes the resulting rtcStatsUrl to trace.connect().
videoHandler.onSettled$('joinSession', ({ error, result }) => {
if (error || !result) return;
const session = result as VideoSessionDetailsWithToken;
const rtcStatsToken = jwt.sign(
{ rtcStats: { conference: session.roomName ?? '', session: session.sessionId } },
process.env.RTCSTATS_JWT_SECRET ?? '',
{ expiresIn: '10m' }
);
const rtcStatsEndpoint = process.env.RTCSTATS_ENDPOINT ?? 'ws://localhost:3000';
(session as { rtcStatsUrl?: string }).rtcStatsUrl =
`${rtcStatsEndpoint}/?rtcstats-token=${rtcStatsToken}`;
});
The rtcStats claim tags each dump so it lands on rtcstats.com associated with the right call. It carries two fields, both read from the decoded session, never from the client:
conference- the room name.session- the Vonage session id.
Two things follow from minting here. First, the secret is what makes rtcstats-server accept the connection at all: your backend holds it, so only URLs your backend signs are accepted, and a bad or missing token closes the socket with code 1008. Second, because conference and session are read from the authoritative session the handler just decoded, they always match the real call and cannot be forged by the browser. Dumps are grouped on rtcstats.com by room and session.
Step 5: open the connection from the credential
The URL now rides along with the Vonage credential. Add it to the credential type:
// frontend/src/types/session.ts
export type Credential = {
sessionKey: string;
token: string;
rtcStatsUrl?: string;
};
Thread it through where the join result becomes the credential:
// frontend/src/Context/SessionProvider/session.tsx
const session = await videoClient.joinSession({ sessionKey });
return connect({
sessionKey,
token: session.token,
// rtcStatsUrl is attached to the result at runtime by the backend onSettled$ hook,
// so it is not part of the statically inferred type.
rtcStatsUrl: (session as { rtcStatsUrl?: string }).rtcStatsUrl,
});
Then open the trace in the VonageVideoClient constructor, right after initSession:
// frontend/src/utils/VonageVideoClient/vonageVideoClient.ts
import rtcStatsTrace from '../../rtcStatsTrace';
// ... inside the constructor, after initSession(...)
if (credential.rtcStatsUrl) {
rtcStatsTrace.connect(credential.rtcStatsUrl);
}
rtcStatsTrace.connect(url) opens one WebSocket connection to rtcstats-server, which writes and processes the dump on its side. No second fetch, and the Vonage token and the rtcstats URL can never disagree about which session they describe.
Step 6: configure the backend
The mint step reads two environment variables. Add them to your backend .env (gitignored):
RTCSTATS_ENDPOINT- the rtcstats-server WebSocket URL (e.g.ws://localhost:3000).RTCSTATS_JWT_SECRET- must match the rtcstats-server's secret. A mismatch closes the WebSocket with code1008and no dump is written.
Verify it works
With rtcstats-server running, join a Vonage session. The server emits one log line on connect and another on disconnect when you leave:
Accepted new connection with uuid 93a750f8-223a-4c64-a39f-4c01f2731ae3
Connection with uuid 93a750f8-223a-4c64-a39f-4c01f2731ae3 disconnected, starting to process data
That pair confirms the connection opened and the dump was processed. Your session data is now flowing to rtcstats-server.
NOTE: Can't get this to work? Need help? Contact us
Was this page helpful?