@observertc/client-monitor-js
Version:
ObserveRTC Client Integration Javascript Library
1 lines • 15.5 kB
JavaScript
import{schemaVersion as e}from"./schema/ClientSample";import{createLogger as t}from"./utils/logger";import i from"eventemitter3";import{ClientEventTypes as s}from"./schema/ClientEventTypes";import{Sources as o}from"./sources/Sources";import{Detectors as n}from"./detectors/Detectors";import{CpuPerformanceDetector as r}from"./detectors/CpuPerformanceDetector";import{StatsGapDetector as a}from"./detectors/StatsGapDetector";import{DefaultScoreCalculator as c}from"./scores/DefaultScoreCalculator";import{inferSourceType as l}from"./sources/inferSourceType";import{ClientEventPayloadProvider as d}from"./sources/ClientEventPayloadProvider";const h="ClientMonitor";export class ClientMonitor extends i{static samplingSchemaVersion=e;mappedPeerConnections=new Map;detectors;clientEventPayloadProvider=new d;extensionStatsProviders=new Set;activeIssues=new Map;scoreCalculator;logger;closed=!1;lastSampledAt=0;lastCollectingStatsAt=0;cpuPerformanceAlertOn=!1;sendingAudioBitrate=-1;sendingVideoBitrate=-1;receivingAudioBitrate=-1;receivingVideoBitrate=-1;totalAvailableIncomingBitrate=-1;totalAvailableOutgoingBitrate=-1;avgRttInSec=-1;score=5;scoreReasons;_browser;_sources;_timer;_samplingTick=0;_collectingCounter=0;_clientEvents=[];_clientMetaItems=[];_clientIssues=[];_extensionStats=[];durationOfCollectingStatsInMs=0;config;attachments;constructor(e){super();const i=e??{};this.logger=i.logger??t();const s=(e,t)=>void 0===e?t:e;this.config={...i,collectingPeriodInMs:i.collectingPeriodInMs??2e3,samplingPeriodInMs:i.samplingPeriodInMs??8e3,integrateNavigatorMediaDevices:i.integrateNavigatorMediaDevices??!0,addClientJointEventOnCreated:i.addClientJointEventOnCreated??!0,addClientLeftEventOnClose:i.addClientLeftEventOnClose??!0,videoFreezesDetector:s(i.videoFreezesDetector,{}),dryInboundTrackDetector:s(i.dryInboundTrackDetector,{thresholdInMs:5e3}),dryOutboundTrackDetector:s(i.dryOutboundTrackDetector,{thresholdInMs:5e3}),audioDesyncDetector:s(i.audioDesyncDetector,{fractionalCorrectionAlertOffThreshold:.05,fractionalCorrectionAlertOnThreshold:.1}),syntheticSamplesDetector:s(i.syntheticSamplesDetector,{minSynthesizedSamplesDuration:0}),congestionDetector:s(i.congestionDetector,{sensitivity:"medium"}),cpuPerformanceDetector:s(i.cpuPerformanceDetector,{incomingDecodedFramesRatioThresholds:{alertOn:.7,alertOff:.85,minReceivedFrames:10},durationOfCollectingStatsThreshold:{lowWatermark:5e3,highWatermark:1e4},encoderCpuLimitationShareThreshold:.3,encodeTimeBudgetRatio:.8}),audioConcealmentDetector:s(i.audioConcealmentDetector,{onThreshold:.03,offThreshold:.01,windowInMs:15e3,minSamplesInWindow:24e3}),jitterBufferStressDetector:s(i.jitterBufferStressDetector,{targetDelayThresholdInMs:200,timeStretchThreshold:.02,minConsecutiveTicks:2}),decoderPerformanceDetector:s(i.decoderPerformanceDetector,{decodeTimeBudgetRatio:.8,dropRatioThreshold:.1,minFramesReceived:10,quietLossThreshold:.02,minConsecutiveTicks:2}),videoRecoveryDetector:s(i.videoRecoveryDetector,{windowInMs:3e4,pliRateAlertOn:.5,pliRateAlertOff:.15,recoveryFailedThresholdInMs:5e3,recoveryFailedMinPliCount:2}),sourceEncoderBottleneckDetector:s(i.sourceEncoderBottleneckDetector,{captureFpsRatioThreshold:.5,minSourceFps:5,encodeFpsRatioThreshold:.7,encodeTimeBudgetRatio:.8,cpuLimitationShareThreshold:.3,minConsecutiveTicks:2}),simulcastLayerDetector:s(i.simulcastLayerDetector,{createEvent:!0}),captureFailureDetector:s(i.captureFailureDetector,{silenceThresholdInMs:3e4,silenceRmsThreshold:.001,createEvent:!0}),codecChangeDetector:s(i.codecChangeDetector,{createEvent:!0}),videoResolutionChangeDetector:s(i.videoResolutionChangeDetector,{createEvent:!0}),stuckDecoderDetector:s(i.stuckDecoderDetector,{thresholdInMs:4e3,rttMultiplier:15,minStuckTicks:2,minBitrate:1e4,minPliCount:2}),statsGapDetector:s(i.statsGapDetector,{gapRatioThreshold:2,minGapInMs:5e3,createEvent:!0}),playoutDiscrepancyDetector:s(i.playoutDiscrepancyDetector,{lowSkewThreshold:2,highSkewThreshold:5}),longPcConnectionEstablishmentDetector:s(i.longPcConnectionEstablishmentDetector,{thresholdInMs:5e3,createEvent:!0}),iceConnectivityDetector:s(i.iceConnectivityDetector,{disconnectedThresholdInMs:5e3,transportStallThresholdInMs:5e3,createEvent:!0,pathSwitchWindowInMs:3e4,pathSwitchThreshold:3,iceRestartRecommendationThresholdInMs:1e4,iceRestartRecommendationCooldownInMs:15e3}),bufferingEventsForSamples:i.bufferingEventsForSamples??!1,appData:i.appData??{}},this._sources=new o(this,this.logger),this.scoreCalculator=new c(this),this.setCollectingPeriod(this.config.collectingPeriodInMs),this.config.samplingPeriodInMs&&this.setSamplingPeriod(this.config.samplingPeriodInMs),!0===this.config.addClientJointEventOnCreated&&this.addClientJoinEvent(),this.config.integrateNavigatorMediaDevices&&this._sources.watchNavigatorMediaDevices();try{this._sources.fetchUserAgentData()}catch(e){this.logger.error(`[${h}]:`,"Failed to fetch user agent data",e)}this.detectors=new n,null!==this.config.cpuPerformanceDetector&&this.detectors.add(new r(this)),null!==this.config.statsGapDetector&&this.detectors.add(new a(this))}get clientId(){return this.config.clientId}set clientId(e){this.config.clientId=e}get callId(){return this.config.callId}set callId(e){this.config.callId=e}get appData(){return this.config.appData}set appData(e){this.config.appData=e}set browser(e){if(!this.closed&&e){this._browser&&this.logger.warn(`[${h}]:`,"Browser info is already set on ClientMonitor, overwriting it"),this._browser=e;for(const e of this.peerConnections)this._sources.addStatsAdapters(e)}}set onsamplecreated(e){this.once("close",(()=>this.off("sample-created",e))),this.on("sample-created",e)}set onstatscollected(e){this.once("close",(()=>this.off("stats-collected",e))),this.on("stats-collected",e)}set onclientevent(e){this.once("close",(()=>this.off("client-event",e))),this.on("client-event",e)}set onissue(e){this.once("close",(()=>this.off("issue",e))),this.on("issue",e)}get browser(){return this._browser}close(){if(!this.closed){clearInterval(this._timer),this._timer=void 0;for(const e of[...this.activeIssues.keys()])this.resolveIssue(e,{comment:"monitor closed before issue could be resolved"});this.config.addClientLeftEventOnClose&&this.addClientLeftEvent({}),0<this._samplingTick&&this.createSample(),this.closed=!0,this.emit("close")}}on(e,t){return super.on(e,t),this}once(e,t){return super.once(e,t),this}off(e,t){return super.off(e,t),this}emit(e,...t){return super.emit(e,...t)}async collect(){if(this.closed)return this.logger.warn(`[${h}]:`,"ClientMonitor is closed, cannot collect stats"),[];this.lastCollectingStatsAt=Date.now();const e=[];if(await Promise.all([...this.peerConnections.map((async t=>{try{const i=await t.collect();e.push([t.peerConnectionId,i])}catch(e){this.logger.error(`[${h}]:`,`Failed to get stats from peer connection ${t.peerConnectionId}`,e)}})),...[...this.extensionStatsProviders.values()].map((async e=>{try{const t=await e();this.addExtensionStats(t)}catch(e){this.logger.error(`[${h}]:`,"Failed to get extension stats",e)}}))]),this.sendingAudioBitrate=this.peerConnections.reduce(((e,t)=>e+(t.sendingAudioBitrate??0)),0),this.sendingVideoBitrate=this.peerConnections.reduce(((e,t)=>e+(t.sendingVideoBitrate??0)),0),this.receivingAudioBitrate=this.peerConnections.reduce(((e,t)=>e+(t.receivingAudioBitrate??0)),0),this.receivingVideoBitrate=this.peerConnections.reduce(((e,t)=>e+(t.receivingVideoBitrate??0)),0),this.totalAvailableIncomingBitrate=this.peerConnections.reduce(((e,t)=>e+(t.totalAvailableIncomingBitrate??0)),0),this.totalAvailableOutgoingBitrate=this.peerConnections.reduce(((e,t)=>e+(t.totalAvailableOutgoingBitrate??0)),0),this.avgRttInSec=0<this.peerConnections.length?this.peerConnections.reduce(((e,t)=>e+(t.avgRttInSec??0)),0)/this.peerConnections.length:-1,this.durationOfCollectingStatsInMs=Date.now()-this.lastCollectingStatsAt,this.tracks.forEach((e=>e.update())),this.detectors.update(),this.scoreCalculator.update(),this.emit("stats-collected",{clientMonitor:this,startedAt:this.lastCollectingStatsAt,collectedStats:e,durationOfCollectingStatsInMs:this.durationOfCollectingStatsInMs}),0<this._samplingTick){++this._collectingCounter%this._samplingTick===0&&this.createSample()}return e}getPeerConnectionMonitor(e){return this.mappedPeerConnections.get(e)}setScore(e,t){this.closed||(this.score=e,this.scoreReasons=t,this.emit("score",{clientMonitor:this,clientScore:e,currentReasons:t??{}}))}createSample(){if(this.closed)return;const e={clientId:this.clientId,timestamp:Date.now(),callId:this.callId,attachments:this.attachments,peerConnections:this.peerConnections.map((e=>e.createSample())),clientEvents:this._clientEvents,clientMetaItems:this._clientMetaItems,clientIssues:this._clientIssues,extensionStats:this._extensionStats,score:this.score};this._clientEvents=[],this._clientMetaItems=[],this._clientIssues=[],this._extensionStats=[];const t=Date.now();return e?(this.emit("sample-created",{clientMonitor:this,sample:e}),this.lastSampledAt=t,e):void 0}addPeerConnectionMonitor(e){if(!this.closed){if(this.mappedPeerConnections.has(e.peerConnectionId))return this.logger.warn(`[${h}]:`,`PeerConnectionMonitor with id ${e.peerConnectionId} already exists`);e.once("close",(()=>{this.mappedPeerConnections.delete(e.peerConnectionId)})),this.mappedPeerConnections.set(e.peerConnectionId,e),this.emit("new-peerconnnection-monitor",{peerConnectionMonitor:e,clientMonitor:this})}}addClientJoinEvent(e){this.closed||this.addEvent({type:s.CLIENT_JOINED,payload:{...e?.payload},timestamp:e?.timestamp??Date.now()})}addClientLeftEvent(e){this.closed||this.addEvent({type:s.CLIENT_LEFT,payload:{...e?.payload},timestamp:e?.timestamp??Date.now()})}addEvent(e){if(this.closed)return;if(!this._samplingTick&&!this.config.bufferingEventsForSamples)return;const t=e.timestamp??Date.now(),i=e.payload?JSON.stringify(e.payload):void 0;this._clientEvents.push({...e,payload:i,timestamp:t}),this.emit("client-event",{...e,payload:e.payload,timestamp:t})}addIssue(e){if(this.closed)return;const t=e.timestamp??Date.now(),i={type:e.type,payload:e.payload,timestamp:t};return this._bufferIssueForSample(i.type,i.payload,t),this.emit("issue",i),i}raiseIssue(e,t){if(this.closed)return;const i=t.timestamp??Date.now(),s=this.activeIssues.get(e);if(s)return s.type=t.type,s.payload=t.payload,s.updatedAt=i,this.emit("issue-updated",s),s;const o={type:t.type,key:e,payload:t.payload,raisedAt:i,updatedAt:i};return this.activeIssues.set(o.key,o),this._bufferIssueForSample(o.type,o.payload,i),this.emit("issue",o),o}resolveIssue(e,t){if(this.closed)return;const i=this.activeIssues.get(e);if(!i)return;this.activeIssues.delete(e),t.payload&&(i.payload=t.payload);const s={...i,resolvedAt:t.resolvedAt??Date.now(),comment:t.comment};return this.emit("issue-resolved",s),s}getActiveIssuesByType(e){const t=[];for(const i of this.activeIssues.values())void 0!==e&&i.type!==e||t.push(i);return t}isIssueActive(e){return this.activeIssues.has(e)}_bufferIssueForSample(e,t,i){(this._samplingTick||this.config.bufferingEventsForSamples)&&this._clientIssues.push({type:e,payload:void 0===t?void 0:JSON.stringify(t),timestamp:i})}addMetaData(e){if(this.closed)return;if(!this._samplingTick&&!this.config.bufferingEventsForSamples)return;const t=e.timestamp??Date.now();this._clientMetaItems.push({type:e.type,payload:e.payload?JSON.stringify(e.payload):void 0,timestamp:t}),this.emit("meta",{...e,payload:e.payload,timestamp:t})}addExtensionStats(e){if(this.closed)return;if(!this._samplingTick&&!this.config.bufferingEventsForSamples)return;const t=e.payload?JSON.stringify(e.payload):void 0;this._extensionStats.push({type:e.type,payload:t}),this.emit("extension-stats",{...e,payload:e.payload})}addSource(e,t){if(this.closed)return this.logger.warn(`[${h}]:`,"Cannot add source to closed ClientMonitor");if(!t&&!(t=l(e)))return this.logger.warn(`[${h}]:`,"Cannot add source to ClientMonitor, because it is not a valid source",e);switch(t){case"RTCPeerConnection":this._sources.addRTCPeerConnection({peerConnection:e});break;case"mediasoup-device":this._sources.addMediasoupDevice(e);break;case"mediasoup-transport":this._sources.addMediasoupTransport(e);break;default:return this.logger.warn(`[${h}]:`,"Cannot add source to ClientMonitor, because it is not a valid source",e)}}removeSource(e,t){if(this.closed)return this.logger.warn(`[${h}]:`,"Cannot remove source from closed ClientMonitor");if(!t&&!(t=l(e)))return this.logger.warn(`[${h}]:`,"Cannot remove source from ClientMonitor, because it is not a valid source",e);switch(t){case"RTCPeerConnection":this._sources.removeRTCPeerConnection(e);break;case"mediasoup-device":this._sources.removeMediasoupDevice(e);break;case"mediasoup-transport":this._sources.removeMediasoupTransport(e);break;default:return this.logger.warn(`[${h}]:`,"Cannot remove source from ClientMonitor, because it is not a valid source",e)}}fetchUserAgentData(){return this._sources.fetchUserAgentData()}watchNavigatorMediaDevices(){this._sources.watchNavigatorMediaDevices()}get peerConnections(){return[...this.mappedPeerConnections.values()]}get codecs(){return[...this.peerConnections.flatMap((e=>e.codecs))]}get inboundRtps(){return[...this.peerConnections.flatMap((e=>e.inboundRtps))]}get outboundRtps(){return[...this.peerConnections.flatMap((e=>e.outboundRtps))]}get remoteInboundRtps(){return[...this.peerConnections.flatMap((e=>e.remoteInboundRtps))]}get remoteOutboundRtps(){return[...this.peerConnections.flatMap((e=>e.remoteOutboundRtps))]}get mediaSources(){return[...this.peerConnections.flatMap((e=>e.mediaSources))]}get mediaPlayouts(){return[...this.peerConnections.flatMap((e=>e.mediaPlayouts))]}get dataChannels(){return[...this.peerConnections.flatMap((e=>e.dataChannels))]}get iceCandidatePairs(){return[...this.peerConnections.flatMap((e=>e.iceCandidatePairs))]}get iceCandidates(){return[...this.peerConnections.flatMap((e=>e.iceCandidates))]}get iceTransports(){return[...this.peerConnections.flatMap((e=>e.iceTransports))]}get certificates(){return[...this.peerConnections.flatMap((e=>e.certificates))]}get tracks(){return[...this.peerConnections.flatMap((e=>e.tracks))]}getTrackMonitor(e){return this.getInboundTrackMonitor(e)??this.getOutboundTrackMonitor(e)}getInboundTrackMonitor(e){return this.peerConnections.find((t=>t.mappedInboundTracks.has(e)))?.mappedInboundTracks.get(e)}getOutboundTrackMonitor(e){return this.peerConnections.find((t=>t.mappedOutboundTracks.has(e)))?.mappedOutboundTracks.get(e)}setCollectingPeriod(e){this._timer&&clearInterval(this._timer),this._timer=void 0,this.config.collectingPeriodInMs=e;try{if(!this.config.collectingPeriodInMs)return;this._timer=setInterval((()=>{this.collect().catch((e=>this.logger.error(`[${h}]:`,e)))}),this.config.collectingPeriodInMs)}finally{this._setSamplingTick()}}setSamplingPeriod(e){this.config.samplingPeriodInMs=e,this._setSamplingTick()}_setSamplingTick(){void 0!==this.config.collectingPeriodInMs&&void 0!==this.config.samplingPeriodInMs?this.config.collectingPeriodInMs<1||this.config.samplingPeriodInMs<1?this._samplingTick=0:(this.config.samplingPeriodInMs%this.config.collectingPeriodInMs!==0&&this.logger.warn(`[${h}]:`,`The samplingPeriodInMs (${this.config.samplingPeriodInMs}) should be a multiple of collectingPeriodInMs (${this.config.collectingPeriodInMs}), otherwise the sampling will not be accurate`),this._samplingTick=Math.max(1,Math.floor(this.config.samplingPeriodInMs/this.config.collectingPeriodInMs))):this._samplingTick=0}}