From 56d1c9fa33487e3792830fb4fb99a06d6715a05d Mon Sep 17 00:00:00 2001 From: Robert Long Date: Tue, 3 Aug 2021 15:05:29 -0700 Subject: [PATCH] Add stats reporting --- src/ConferenceCallManager.js | 301 ++++++++++++++++++++++++++++++++++- src/DevTools.jsx | 18 ++- src/DevTools.module.css | 7 +- 3 files changed, 321 insertions(+), 5 deletions(-) diff --git a/src/ConferenceCallManager.js b/src/ConferenceCallManager.js index 09090a0..0a87e41 100644 --- a/src/ConferenceCallManager.js +++ b/src/ConferenceCallManager.js @@ -188,6 +188,7 @@ export class ConferenceCallManager extends EventEmitter { const userId = call.opponentMember.userId; this._addCall(call, userId); call.answer(); + this._observePeerConnection(call); } this.pendingCalls = []; @@ -199,7 +200,10 @@ export class ConferenceCallManager extends EventEmitter { const roomId = event.getRoomId(); const type = event.getType(); - if (roomId === this.roomId && type.startsWith("m.call.")) { + if ( + roomId === this.roomId && + (type.startsWith("m.call.") || type === "me.robertlong.call.info") + ) { const sender = event.getSender(); const { call_id } = event.getContent(); @@ -333,7 +337,298 @@ export class ConferenceCallManager extends EventEmitter { const call = this.client.createCall(this.roomId, userId); this._addCall(call, userId); - call.placeVideoCall(); + call.placeVideoCall().then(() => { + this._observePeerConnection(call); + }); + } + + _observePeerConnection(call) { + const peerConnection = call.peerConn; + + if (!peerConnection) { + return; + } + + const sendWebRTCInfoEvent = async (eventType) => { + const event = { + call_id: call.callId, + eventType, + iceConnectionState: peerConnection.iceConnectionState, + iceGatheringState: peerConnection.iceGatheringState, + signalingState: peerConnection.signalingState, + selectedCandidatePair: null, + localCandidate: null, + remoteCandidate: null, + }; + + // getStats doesn't support selectors in Firefox so get all stats by passing null. + // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getStats#browser_compatibility + const stats = await peerConnection.getStats(null); + + const statsArr = Array.from(stats.values()); + + // Matrix doesn't support floats so we convert time in seconds to ms + function secToMs(time) { + if (time === undefined) { + return undefined; + } + + return Math.round(time * 1000); + } + + function processTransportStats(transportStats) { + if (!transportStats) { + return undefined; + } + + return { + packetsSent: transportStats.packetsSent, + packetsReceived: transportStats.packetsReceived, + bytesSent: transportStats.bytesSent, + bytesReceived: transportStats.bytesReceived, + iceRole: transportStats.iceRole, + iceState: transportStats.iceState, + dtlsState: transportStats.dtlsState, + dtlsCipher: transportStats.dtlsCipher, + tlsVersion: transportStats.tlsVersion, + }; + } + + function processCandidateStats(candidateStats) { + if (!candidateStats) { + return undefined; + } + + // TODO: Figure out how to normalize ip and address across browsers + // networkType property excluded for privacy reasons: + // https://www.w3.org/TR/webrtc-stats/#sotd + return { + protocol: candidateStats.protocol, + address: candidateStats.address || candidateStats.ip, + port: candidateStats.port, + url: candidateStats.url, + relayProtocol: candidateStats.relayProtocol, + }; + } + + function processCandidatePair(candidatePairStats) { + if (!candidatePairStats) { + return undefined; + } + + const localCandidateStats = statsArr.find( + (stat) => stat.id === candidatePairStats.localCandidateId + ); + event.localCandidate = processCandidateStats(localCandidateStats); + + const remoteCandidateStats = statsArr.find( + (stat) => stat.id === candidatePairStats.remoteCandidateId + ); + event.remoteCandidate = processCandidateStats(remoteCandidateStats); + + const transportStats = statsArr.find( + (stat) => stat.id === candidatePairStats.transportId + ); + event.transport = processTransportStats(transportStats); + + return { + state: candidatePairStats.state, + bytesSent: candidatePairStats.bytesSent, + bytesReceived: candidatePairStats.bytesReceived, + requestsSent: candidatePairStats.requestsSent, + requestsReceived: candidatePairStats.requestsReceived, + responsesSent: candidatePairStats.responsesSent, + responsesReceived: candidatePairStats.responsesReceived, + currentRoundTripTime: secToMs( + candidatePairStats.currentRoundTripTime + ), + totalRoundTripTime: secToMs(candidatePairStats.totalRoundTripTime), + }; + } + + // Firefox uses the deprecated "selected" property for the nominated ice candidate. + const selectedCandidatePair = statsArr.find( + (stat) => + stat.type === "candidate-pair" && (stat.selected || stat.nominated) + ); + + event.selectedCandidatePair = processCandidatePair(selectedCandidatePair); + + function processCodecStats(codecStats) { + if (!codecStats) { + return undefined; + } + + // Payload type enums and MIME types listed here: + // https://www.iana.org/assignments/rtp-parameters/rtp-parameters.xhtml + return { + mimeType: codecStats.mimeType, + clockRate: codecStats.clockRate, + payloadType: codecStats.payloadType, + channels: codecStats.channels, + sdpFmtpLine: codecStats.sdpFmtpLine, + }; + } + + function processRTPStreamStats(rtpStreamStats) { + const codecStats = statsArr.find( + (stat) => stat.id === rtpStreamStats.codecId + ); + const codec = processCodecStats(codecStats); + + return { + kind: rtpStreamStats.kind, + codec, + }; + } + + function processInboundRTPStats(inboundRTPStats) { + const rtpStreamStats = processRTPStreamStats(inboundRTPStats); + + return { + ...rtpStreamStats, + decoderImplementation: inboundRTPStats.decoderImplementation, + bytesReceived: inboundRTPStats.bytesReceived, + packetsReceived: inboundRTPStats.packetsReceived, + packetsLost: inboundRTPStats.packetsLost, + jitter: secToMs(inboundRTPStats.jitter), + frameWidth: inboundRTPStats.frameWidth, + frameHeight: inboundRTPStats.frameHeight, + frameBitDepth: inboundRTPStats.frameBitDepth, + framesPerSecond: + inboundRTPStats.framesPerSecond && + inboundRTPStats.framesPerSecond.toString(), + framesReceived: inboundRTPStats.framesReceived, + framesDecoded: inboundRTPStats.framesDecoded, + framesDropped: inboundRTPStats.framesDropped, + totalSamplesDecoded: inboundRTPStats.totalSamplesDecoded, + totalDecodeTime: secToMs(inboundRTPStats.totalDecodeTime), + totalProcessingDelay: secToMs(inboundRTPStats.totalProcessingDelay), + }; + } + + function processOutboundRTPStats(outboundRTPStats) { + const rtpStreamStats = processRTPStreamStats(outboundRTPStats); + + return { + ...rtpStreamStats, + encoderImplementation: outboundRTPStats.encoderImplementation, + bytesSent: outboundRTPStats.bytesSent, + packetsSent: outboundRTPStats.packetsSent, + frameWidth: outboundRTPStats.frameWidth, + frameHeight: outboundRTPStats.frameHeight, + frameBitDepth: outboundRTPStats.frameBitDepth, + framesPerSecond: + outboundRTPStats.framesPerSecond && + outboundRTPStats.framesPerSecond.toString(), + framesSent: outboundRTPStats.framesSent, + framesEncoded: outboundRTPStats.framesEncoded, + qualityLimitationReason: outboundRTPStats.qualityLimitationReason, + qualityLimitationResolutionChanges: + outboundRTPStats.qualityLimitationResolutionChanges, + totalEncodeTime: secToMs(outboundRTPStats.totalEncodeTime), + totalPacketSendDelay: secToMs(outboundRTPStats.totalPacketSendDelay), + }; + } + + function processRemoteInboundRTPStats(remoteInboundRTPStats) { + const rtpStreamStats = processRTPStreamStats(remoteInboundRTPStats); + + return { + ...rtpStreamStats, + packetsReceived: remoteInboundRTPStats.packetsReceived, + packetsLost: remoteInboundRTPStats.packetsLost, + jitter: secToMs(remoteInboundRTPStats.jitter), + framesDropped: remoteInboundRTPStats.framesDropped, + roundTripTime: secToMs(remoteInboundRTPStats.roundTripTime), + totalRoundTripTime: secToMs(remoteInboundRTPStats.totalRoundTripTime), + fractionLost: + remoteInboundRTPStats.fractionLost !== undefined && + remoteInboundRTPStats.fractionLost.toString(), + reportsReceived: remoteInboundRTPStats.reportsReceived, + roundTripTimeMeasurements: + remoteInboundRTPStats.roundTripTimeMeasurements, + }; + } + + function processRemoteOutboundRTPStats(remoteOutboundRTPStats) { + const rtpStreamStats = processRTPStreamStats(remoteOutboundRTPStats); + + return { + ...rtpStreamStats, + encoderImplementation: remoteOutboundRTPStats.encoderImplementation, + bytesSent: remoteOutboundRTPStats.bytesSent, + packetsSent: remoteOutboundRTPStats.packetsSent, + roundTripTime: secToMs(remoteOutboundRTPStats.roundTripTime), + totalRoundTripTime: secToMs( + remoteOutboundRTPStats.totalRoundTripTime + ), + reportsSent: remoteOutboundRTPStats.reportsSent, + roundTripTimeMeasurements: + remoteOutboundRTPStats.roundTripTimeMeasurements, + }; + } + + event.inboundRTP = statsArr + .filter((stat) => stat.type === "inbound-rtp") + .map(processInboundRTPStats); + + event.outboundRTP = statsArr + .filter((stat) => stat.type === "outbound-rtp") + .map(processOutboundRTPStats); + + event.remoteInboundRTP = statsArr + .filter((stat) => stat.type === "remote-inbound-rtp") + .map(processRemoteInboundRTPStats); + + event.remoteOutboundRTP = statsArr + .filter((stat) => stat.type === "remote-outbound-rtp") + .map(processRemoteOutboundRTPStats); + + this.client.sendEvent(this.roomId, "me.robertlong.call.info", event); + }; + + let statsTimeout; + + const sendStats = () => { + sendWebRTCInfoEvent("stats"); + statsTimeout = setTimeout(sendStats, 30 * 1000); + }; + + setTimeout(sendStats, 30 * 1000); + + peerConnection.addEventListener("iceconnectionstatechange", () => { + sendWebRTCInfoEvent("iceconnectionstatechange"); + }); + peerConnection.addEventListener("icegatheringstatechange", () => { + sendWebRTCInfoEvent("icegatheringstatechange"); + }); + peerConnection.addEventListener("negotiationneeded", () => { + sendWebRTCInfoEvent("negotiationneeded"); + }); + peerConnection.addEventListener("track", () => { + sendWebRTCInfoEvent("track"); + }); + // NOTE: Not available on Firefox + // https://bugzilla.mozilla.org/show_bug.cgi?id=1561441 + peerConnection.addEventListener( + "icecandidateerror", + ({ errorCode, url, errorText }) => { + this.client.sendEvent(this.roomId, "me.robertlong.call.ice_error", { + call_id: call.callId, + errorCode, + url, + errorText, + }); + } + ); + peerConnection.addEventListener("signalingstatechange", () => { + sendWebRTCInfoEvent("signalingstatechange"); + + if (peerConnection.signalingState === "closed") { + clearTimeout(statsTimeout); + } + }); } _onIncomingCall = (call) => { @@ -376,6 +671,7 @@ export class ConferenceCallManager extends EventEmitter { const userId = call.opponentMember.userId; this._addCall(call, userId); call.answer(); + this._observePeerConnection(call); }; _addCall(call, userId) { @@ -485,6 +781,7 @@ export class ConferenceCallManager extends EventEmitter { ); remoteParticipant.call = newCall; + this._observePeerConnection(newCall); newCall.on("feeds_changed", () => this._onCallFeedsChanged(newCall)); newCall.on("hangup", () => this._onCallHangup(newCall)); diff --git a/src/DevTools.jsx b/src/DevTools.jsx index c383870..9a3fc92 100644 --- a/src/DevTools.jsx +++ b/src/DevTools.jsx @@ -155,19 +155,33 @@ function EventContainer({ title, state, events, ...rest }) { function EventItem({ event, showCallId, showSender, onSelect }) { const type = event.getType(); const sender = event.getSender(); - const { call_id, invitee, reason } = event.getContent(); + const { call_id, invitee, reason, eventType, ...rest } = event.getContent(); + + let eventValue; + + if (eventType === "icegatheringstatechange") { + eventValue = rest.iceGatheringState; + } else if (eventType === "iceconnectionstatechange") { + eventValue = rest.iceConnectionState; + } else if (eventType === "signalingstatechange") { + eventValue = rest.signalingState; + } return (
onSelect(event)}> {showSender && sender && ( )} - {type} + + {type.replace("me.robertlong.", "x.")} + {showCallId && call_id && ( )} {invitee && } {reason && {reason}} + {eventType && {eventType}} + {eventValue && {eventValue}}
); } diff --git a/src/DevTools.module.css b/src/DevTools.module.css index c96a982..4f6e0c1 100644 --- a/src/DevTools.module.css +++ b/src/DevTools.module.css @@ -34,7 +34,7 @@ flex-direction: column; flex: 1; background-color: #555; - min-width: 320px; + min-width: 512px; } .userId { @@ -64,12 +64,17 @@ font-family: monospace; padding: 4px 12px; background-color: #333; + cursor: pointer; } .event:nth-child(even) { background-color: #444; } +.event:hover { + background-color: #555; +} + .event > * { margin-right: 4px; }