From ec5f6f27edaf720a84fb476b96ec882f7af29061 Mon Sep 17 00:00:00 2001 From: Aleksandr Alekseev Date: Tue, 8 Aug 2023 19:20:36 +0300 Subject: [PATCH] Stats: improve JSON support, add missing structs - Fix json marshalling of stats containing enums - Add UnmarshalStatsJSON helper - Add marshalling/unmarshalling tests - Add missing AudioSourceStats, VideoSourceStats AudioPlayoutStats defined in https://www.w3.org/TR/webrtc-stats - Deprecate ICECandidateStats' NetworkType, use plain string instead of enum which does not suite the definition: https://clck.ru/354H9r --- AUTHORS.txt | 1 + datachannelstate.go | 11 + dtlstransportstate.go | 11 + icecandidatetype.go | 12 + icegatherer.go | 2 - icerole.go | 11 + stats.go | 539 +++++++++++++++++++++- stats_go_test.go | 1000 +++++++++++++++++++++++++++++++++++++++-- 8 files changed, 1554 insertions(+), 33 deletions(-) diff --git a/AUTHORS.txt b/AUTHORS.txt index 6b50c70751..dcfc1193fd 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -12,6 +12,7 @@ Adrian Cable adwpc aggresss akil +Aleksandr Alekseev Aleksandr Razumov aler9 <46489434+aler9@users.noreply.github.com> Alex Browne diff --git a/datachannelstate.go b/datachannelstate.go index b2a85aaeb5..55dc916e97 100644 --- a/datachannelstate.go +++ b/datachannelstate.go @@ -62,3 +62,14 @@ func (t DataChannelState) String() string { return ErrUnknownType.Error() } } + +// MarshalText implements encoding.TextMarshaler +func (t DataChannelState) MarshalText() ([]byte, error) { + return []byte(t.String()), nil +} + +// UnmarshalText implements encoding.TextUnmarshaler +func (t *DataChannelState) UnmarshalText(b []byte) error { + *t = newDataChannelState(string(b)) + return nil +} diff --git a/dtlstransportstate.go b/dtlstransportstate.go index f986e22cbb..573dd4bf03 100644 --- a/dtlstransportstate.go +++ b/dtlstransportstate.go @@ -72,3 +72,14 @@ func (t DTLSTransportState) String() string { return ErrUnknownType.Error() } } + +// MarshalText implements encoding.TextMarshaler +func (t DTLSTransportState) MarshalText() ([]byte, error) { + return []byte(t.String()), nil +} + +// UnmarshalText implements encoding.TextUnmarshaler +func (t *DTLSTransportState) UnmarshalText(b []byte) error { + *t = newDTLSTransportState(string(b)) + return nil +} diff --git a/icecandidatetype.go b/icecandidatetype.go index a274c45dbc..5bef825b30 100644 --- a/icecandidatetype.go +++ b/icecandidatetype.go @@ -95,3 +95,15 @@ func getCandidateType(candidateType ice.CandidateType) (ICECandidateType, error) return ICECandidateType(Unknown), err } } + +// MarshalText implements the encoding.TextMarshaler interface. +func (t ICECandidateType) MarshalText() ([]byte, error) { + return []byte(t.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface. +func (t *ICECandidateType) UnmarshalText(b []byte) error { + var err error + *t, err = NewICECandidateType(string(b)) + return err +} diff --git a/icegatherer.go b/icegatherer.go index 03762cf76d..4265c5daa3 100644 --- a/icegatherer.go +++ b/icegatherer.go @@ -347,7 +347,6 @@ func (g *ICEGatherer) collectStats(collector *statsReportCollector) { Timestamp: statsTimestampFrom(candidateStats.Timestamp), ID: candidateStats.ID, Type: StatsTypeLocalCandidate, - NetworkType: networkType, IP: candidateStats.IP, Port: int32(candidateStats.Port), Protocol: networkType.Protocol(), @@ -376,7 +375,6 @@ func (g *ICEGatherer) collectStats(collector *statsReportCollector) { Timestamp: statsTimestampFrom(candidateStats.Timestamp), ID: candidateStats.ID, Type: StatsTypeRemoteCandidate, - NetworkType: networkType, IP: candidateStats.IP, Port: int32(candidateStats.Port), Protocol: networkType.Protocol(), diff --git a/icerole.go b/icerole.go index 99268bace7..a46b45590f 100644 --- a/icerole.go +++ b/icerole.go @@ -46,3 +46,14 @@ func (t ICERole) String() string { return ErrUnknownType.Error() } } + +// MarshalText implements encoding.TextMarshaler +func (t ICERole) MarshalText() ([]byte, error) { + return []byte(t.String()), nil +} + +// UnmarshalText implements encoding.TextUnmarshaler +func (t *ICERole) UnmarshalText(b []byte) error { + *t = newICERole(string(b)) + return nil +} diff --git a/stats.go b/stats.go index 5a43cb7228..ddf6a53751 100644 --- a/stats.go +++ b/stats.go @@ -4,6 +4,7 @@ package webrtc import ( + "encoding/json" "fmt" "sync" "time" @@ -13,7 +14,63 @@ import ( // A Stats object contains a set of statistics copies out of a monitored component // of the WebRTC stack at a specific time. -type Stats interface{} +type Stats interface { + statsMarker() +} + +// UnmarshalStatsJSON unmarshals a Stats object from JSON +func UnmarshalStatsJSON(b []byte) (Stats, error) { + type typeJSON struct { + Type StatsType `json:"type"` + } + typeHolder := typeJSON{} + + err := json.Unmarshal(b, &typeHolder) + if err != nil { + return nil, fmt.Errorf("unmarshal json type: %w", err) + } + + switch typeHolder.Type { + case StatsTypeCodec: + return unmarshalCodecStats(b) + case StatsTypeInboundRTP: + return unmarshalInboundRTPStreamStats(b) + case StatsTypeOutboundRTP: + return unmarshalOutboundRTPStreamStats(b) + case StatsTypeRemoteInboundRTP: + return unmarshalRemoteInboundRTPStreamStats(b) + case StatsTypeRemoteOutboundRTP: + return unmarshalRemoteOutboundRTPStreamStats(b) + case StatsTypeCSRC: + return unmarshalCSRCStats(b) + case StatsTypeMediaSource: + return unmarshalMediaSourceStats(b) + case StatsTypeMediaPlayout: + return unmarshalMediaPlayoutStats(b) + case StatsTypePeerConnection: + return unmarshalPeerConnectionStats(b) + case StatsTypeDataChannel: + return unmarshalDataChannelStats(b) + case StatsTypeStream: + return unmarshalStreamStats(b) + case StatsTypeTrack: + return unmarshalTrackStats(b) + case StatsTypeSender: + return unmarshalSenderStats(b) + case StatsTypeReceiver: + return unmarshalReceiverStats(b) + case StatsTypeTransport: + return unmarshalTransportStats(b) + case StatsTypeCandidatePair: + return unmarshalICECandidatePairStats(b) + case StatsTypeLocalCandidate, StatsTypeRemoteCandidate: + return unmarshalICECandidateStats(b) + case StatsTypeCertificate: + return unmarshalCertificateStats(b) + default: + return nil, fmt.Errorf("type: %w", ErrUnknownType) + } +} // StatsType indicates the type of the object that a Stats object represents. type StatsType string @@ -37,6 +94,12 @@ const ( // StatsTypeCSRC is used by RTPContributingSourceStats. StatsTypeCSRC StatsType = "csrc" + // StatsTypeMediaSource is used by AudioSourceStats or VideoSourceStats depending on kind. + StatsTypeMediaSource = "media-source" + + // StatsTypeMediaPlayout is used by AudioPlayoutStats. + StatsTypeMediaPlayout StatsType = "media-playout" + // StatsTypePeerConnection used by PeerConnectionStats. StatsTypePeerConnection StatsType = "peer-connection" @@ -46,10 +109,10 @@ const ( // StatsTypeStream is used by MediaStreamStats. StatsTypeStream StatsType = "stream" - // StatsTypeTrack is used by SenderVideoTrackAttachmentStats and SenderAudioTrackAttachmentStats. + // StatsTypeTrack is used by SenderVideoTrackAttachmentStats and SenderAudioTrackAttachmentStats depending on kind. StatsTypeTrack StatsType = "track" - // StatsTypeSender is used by by the AudioSenderStats or VideoSenderStats depending on kind. + // StatsTypeSender is used by the AudioSenderStats or VideoSenderStats depending on kind. StatsTypeSender StatsType = "sender" // StatsTypeReceiver is used by the AudioReceiverStats or VideoReceiverStats depending on kind. @@ -71,6 +134,16 @@ const ( StatsTypeCertificate StatsType = "certificate" ) +// MediaKind indicates the kind of media (audio or video) +type MediaKind string + +const ( + // MediaKindAudio indicates this is audio stats + MediaKindAudio MediaKind = "audio" + // MediaKindVideo indicates this is video stats + MediaKindVideo MediaKind = "video" +) + // StatsTimestamp is a timestamp represented by the floating point number of // milliseconds since the epoch. type StatsTimestamp float64 @@ -183,6 +256,17 @@ type CodecStats struct { Implementation string `json:"implementation"` } +func (s CodecStats) statsMarker() {} + +func unmarshalCodecStats(b []byte) (CodecStats, error) { + var codecStats CodecStats + err := json.Unmarshal(b, &codecStats) + if err != nil { + return CodecStats{}, fmt.Errorf("unmarshal codec stats: %w", err) + } + return codecStats, nil +} + // InboundRTPStreamStats contains statistics for an inbound RTP stream that is // currently received with this PeerConnection object. type InboundRTPStreamStats struct { @@ -332,6 +416,17 @@ type InboundRTPStreamStats struct { PerDSCPPacketsReceived map[string]uint32 `json:"perDscpPacketsReceived"` } +func (s InboundRTPStreamStats) statsMarker() {} + +func unmarshalInboundRTPStreamStats(b []byte) (InboundRTPStreamStats, error) { + var inboundRTPStreamStats InboundRTPStreamStats + err := json.Unmarshal(b, &inboundRTPStreamStats) + if err != nil { + return InboundRTPStreamStats{}, fmt.Errorf("unmarshal inbound rtp stream stats: %w", err) + } + return inboundRTPStreamStats, nil +} + // QualityLimitationReason lists the reason for limiting the resolution and/or framerate. // Only valid for video. type QualityLimitationReason string @@ -477,6 +572,17 @@ type OutboundRTPStreamStats struct { PerDSCPPacketsSent map[string]uint32 `json:"perDscpPacketsSent"` } +func (s OutboundRTPStreamStats) statsMarker() {} + +func unmarshalOutboundRTPStreamStats(b []byte) (OutboundRTPStreamStats, error) { + var outboundRTPStreamStats OutboundRTPStreamStats + err := json.Unmarshal(b, &outboundRTPStreamStats) + if err != nil { + return OutboundRTPStreamStats{}, fmt.Errorf("unmarshal outbound rtp stream stats: %w", err) + } + return outboundRTPStreamStats, nil +} + // RemoteInboundRTPStreamStats contains statistics for the remote endpoint's inbound // RTP stream corresponding to an outbound stream that is currently sent with this // PeerConnection object. It is measured at the remote endpoint and reported in an RTCP @@ -581,10 +687,21 @@ type RemoteInboundRTPStreamStats struct { // RTCP timestamps in the RTCP Receiver Report (RR) and measured in seconds. RoundTripTime float64 `json:"roundTripTime"` - // FractionLost is the the fraction packet loss reported for this SSRC. + // FractionLost is the fraction packet loss reported for this SSRC. FractionLost float64 `json:"fractionLost"` } +func (s RemoteInboundRTPStreamStats) statsMarker() {} + +func unmarshalRemoteInboundRTPStreamStats(b []byte) (RemoteInboundRTPStreamStats, error) { + var remoteInboundRTPStreamStats RemoteInboundRTPStreamStats + err := json.Unmarshal(b, &remoteInboundRTPStreamStats) + if err != nil { + return RemoteInboundRTPStreamStats{}, fmt.Errorf("unmarshal remote inbound rtp stream stats: %w", err) + } + return remoteInboundRTPStreamStats, nil +} + // RemoteOutboundRTPStreamStats contains statistics for the remote endpoint's outbound // RTP stream corresponding to an inbound stream that is currently received with this // PeerConnection object. It is measured at the remote endpoint and reported in an @@ -671,6 +788,17 @@ type RemoteOutboundRTPStreamStats struct { RemoteTimestamp StatsTimestamp `json:"remoteTimestamp"` } +func (s RemoteOutboundRTPStreamStats) statsMarker() {} + +func unmarshalRemoteOutboundRTPStreamStats(b []byte) (RemoteOutboundRTPStreamStats, error) { + var remoteOutboundRTPStreamStats RemoteOutboundRTPStreamStats + err := json.Unmarshal(b, &remoteOutboundRTPStreamStats) + if err != nil { + return RemoteOutboundRTPStreamStats{}, fmt.Errorf("unmarshal remote outbound rtp stream stats: %w", err) + } + return remoteOutboundRTPStreamStats, nil +} + // RTPContributingSourceStats contains statistics for a contributing source (CSRC) that contributed // to an inbound RTP stream. type RTPContributingSourceStats struct { @@ -707,6 +835,211 @@ type RTPContributingSourceStats struct { AudioLevel float64 `json:"audioLevel"` } +func (s RTPContributingSourceStats) statsMarker() {} + +func unmarshalCSRCStats(b []byte) (RTPContributingSourceStats, error) { + var csrcStats RTPContributingSourceStats + err := json.Unmarshal(b, &csrcStats) + if err != nil { + return RTPContributingSourceStats{}, fmt.Errorf("unmarshal csrc stats: %w", err) + } + return csrcStats, nil +} + +// AudioSourceStats represents an audio track that is attached to one or more senders. +type AudioSourceStats struct { + // Timestamp is the timestamp associated with this object. + Timestamp StatsTimestamp `json:"timestamp"` + + // Type is the object's StatsType + Type StatsType `json:"type"` + + // ID is a unique id that is associated with the component inspected to produce + // this Stats object. Two Stats objects will have the same ID if they were produced + // by inspecting the same underlying object. + ID string `json:"id"` + + // TrackIdentifier represents the id property of the track. + TrackIdentifier string `json:"trackIdentifier"` + + // Kind is "audio" + Kind string `json:"kind"` + + // AudioLevel represents the output audio level of the track. + // + // The value is a value between 0..1 (linear), where 1.0 represents 0 dBov, + // 0 represents silence, and 0.5 represents approximately 6 dBSPL change in + // the sound pressure level from 0 dBov. + // + // If the track is sourced from an Receiver, does no audio processing, has a + // constant level, and has a volume setting of 1.0, the audio level is expected + // to be the same as the audio level of the source SSRC, while if the volume setting + // is 0.5, the AudioLevel is expected to be half that value. + AudioLevel float64 `json:"audioLevel"` + + // TotalAudioEnergy is the total energy of all the audio samples sent/received + // for this object, calculated by duration * Math.pow(energy/maxEnergy, 2) for + // each audio sample seen. + TotalAudioEnergy float64 `json:"totalAudioEnergy"` + + // TotalSamplesDuration represents the total duration in seconds of all samples + // that have sent or received (and thus counted by TotalSamplesSent or TotalSamplesReceived). + // Can be used with TotalAudioEnergy to compute an average audio level over different intervals. + TotalSamplesDuration float64 `json:"totalSamplesDuration"` + + // EchoReturnLoss is only present while the sender is sending a track sourced from + // a microphone where echo cancellation is applied. Calculated in decibels. + EchoReturnLoss float64 `json:"echoReturnLoss"` + + // EchoReturnLossEnhancement is only present while the sender is sending a track + // sourced from a microphone where echo cancellation is applied. Calculated in decibels. + EchoReturnLossEnhancement float64 `json:"echoReturnLossEnhancement"` + + // DroppedSamplesDuration represents the total duration, in seconds, of samples produced by the device that got + // dropped before reaching the media source. Only applicable if this media source is backed by an audio capture device. + DroppedSamplesDuration float64 `json:"droppedSamplesDuration"` + + // DroppedSamplesEvents is the number of dropped samples events. This counter increases every time a sample is + // dropped after a non-dropped sample. That is, multiple consecutive dropped samples will increase + // droppedSamplesDuration multiple times but is a single dropped samples event. + DroppedSamplesEvents uint64 `json:"droppedSamplesEvents"` + + // TotalCaptureDelay is the total delay, in seconds, for each audio sample between the time the sample was emitted + // by the capture device and the sample reaching the source. This can be used together with totalSamplesCaptured to + // calculate the average capture delay per sample. Only applicable if the audio source represents an audio capture device. + TotalCaptureDelay float64 `json:"totalCaptureDelay"` + + // TotalSamplesCaptured is the total number of captured samples reaching the audio source, i.e. that were not dropped + // by the capture pipeline. The frequency of the media source is not necessarily the same as the frequency of encoders + // later in the pipeline. Only applicable if the audio source represents an audio capture device. + TotalSamplesCaptured uint64 `json:"totalSamplesCaptured"` +} + +func (s AudioSourceStats) statsMarker() {} + +// VideoSourceStats represents a video track that is attached to one or more senders. +type VideoSourceStats struct { + // Timestamp is the timestamp associated with this object. + Timestamp StatsTimestamp `json:"timestamp"` + + // Type is the object's StatsType + Type StatsType `json:"type"` + + // ID is a unique id that is associated with the component inspected to produce + // this Stats object. Two Stats objects will have the same ID if they were produced + // by inspecting the same underlying object. + ID string `json:"id"` + + // TrackIdentifier represents the id property of the track. + TrackIdentifier string `json:"trackIdentifier"` + + // Kind is "video" + Kind string `json:"kind"` + + // Width is width of the last frame originating from this source in pixels. + Width uint32 `json:"width"` + + // Height is height of the last frame originating from this source in pixels. + Height uint32 `json:"height"` + + // Frames is the total number of frames originating from this source. + Frames uint32 `json:"frames"` + + // FramesPerSecond is the number of frames originating from this source, measured during the last second. + FramesPerSecond float64 `json:"framesPerSecond"` +} + +func (s VideoSourceStats) statsMarker() {} + +func unmarshalMediaSourceStats(b []byte) (Stats, error) { + type kindJSON struct { + Kind string `json:"kind"` + } + kindHolder := kindJSON{} + + err := json.Unmarshal(b, &kindHolder) + if err != nil { + return nil, fmt.Errorf("unmarshal json kind: %w", err) + } + + switch MediaKind(kindHolder.Kind) { + case MediaKindAudio: + var mediaSourceStats AudioSourceStats + err := json.Unmarshal(b, &mediaSourceStats) + if err != nil { + return nil, fmt.Errorf("unmarshal audio source stats: %w", err) + } + return mediaSourceStats, nil + case MediaKindVideo: + var mediaSourceStats VideoSourceStats + err := json.Unmarshal(b, &mediaSourceStats) + if err != nil { + return nil, fmt.Errorf("unmarshal video source stats: %w", err) + } + return mediaSourceStats, nil + default: + return nil, fmt.Errorf("kind: %w", ErrUnknownType) + } +} + +// AudioPlayoutStats represents one playout path - if the same playout stats object is referenced by multiple +// RTCInboundRtpStreamStats this is an indication that audio mixing is happening in which case sample counters in this +// stats object refer to the samples after mixing. Only applicable if the playout path represents an audio device. +type AudioPlayoutStats struct { + // Timestamp is the timestamp associated with this object. + Timestamp StatsTimestamp `json:"timestamp"` + + // Type is the object's StatsType + Type StatsType `json:"type"` + + // ID is a unique id that is associated with the component inspected to produce + // this Stats object. Two Stats objects will have the same ID if they were produced + // by inspecting the same underlying object. + ID string `json:"id"` + + // Kind is "audio" + Kind string `json:"kind"` + + // SynthesizedSamplesDuration is measured in seconds and is incremented each time an audio sample is synthesized by + // this playout path. This metric can be used together with totalSamplesDuration to calculate the percentage of played + // out media being synthesized. If the playout path is unable to produce audio samples on time for device playout, + // samples are synthesized to be playout out instead. Synthesization typically only happens if the pipeline is + // underperforming. Samples synthesized by the RTCInboundRtpStreamStats are not counted for here, but in + // InboundRtpStreamStats.concealedSamples. + SynthesizedSamplesDuration float64 `json:"synthesizedSamplesDuration"` + + // SynthesizedSamplesEvents is the number of synthesized samples events. This counter increases every time a sample + // is synthesized after a non-synthesized sample. That is, multiple consecutive synthesized samples will increase + // synthesizedSamplesDuration multiple times but is a single synthesization samples event. + SynthesizedSamplesEvents uint64 `json:"synthesizedSamplesEvents"` + + // TotalSamplesDuration represents the total duration in seconds of all samples + // that have sent or received (and thus counted by TotalSamplesSent or TotalSamplesReceived). + // Can be used with TotalAudioEnergy to compute an average audio level over different intervals. + TotalSamplesDuration float64 `json:"totalSamplesDuration"` + + // When audio samples are pulled by the playout device, this counter is incremented with the estimated delay of the + // playout path for that audio sample. The playout delay includes the delay from being emitted to the actual time of + // playout on the device. This metric can be used together with totalSamplesCount to calculate the average + // playout delay per sample. + TotalPlayoutDelay float64 `json:"totalPlayoutDelay"` + + // When audio samples are pulled by the playout device, this counter is incremented with the number of samples + // emitted for playout. + TotalSamplesCount uint64 `json:"totalSamplesCount"` +} + +func (s AudioPlayoutStats) statsMarker() {} + +func unmarshalMediaPlayoutStats(b []byte) (Stats, error) { + var audioPlayoutStats AudioPlayoutStats + err := json.Unmarshal(b, &audioPlayoutStats) + if err != nil { + return nil, fmt.Errorf("unmarshal audio playout stats: %w", err) + } + return audioPlayoutStats, nil +} + // PeerConnectionStats contains statistics related to the PeerConnection object. type PeerConnectionStats struct { // Timestamp is the timestamp associated with this object. @@ -741,6 +1074,17 @@ type PeerConnectionStats struct { DataChannelsAccepted uint32 `json:"dataChannelsAccepted"` } +func (s PeerConnectionStats) statsMarker() {} + +func unmarshalPeerConnectionStats(b []byte) (PeerConnectionStats, error) { + var pcStats PeerConnectionStats + err := json.Unmarshal(b, &pcStats) + if err != nil { + return PeerConnectionStats{}, fmt.Errorf("unmarshal pc stats: %w", err) + } + return pcStats, nil +} + // DataChannelStats contains statistics related to each DataChannel ID. type DataChannelStats struct { // Timestamp is the timestamp associated with this object. @@ -784,6 +1128,17 @@ type DataChannelStats struct { BytesReceived uint64 `json:"bytesReceived"` } +func (s DataChannelStats) statsMarker() {} + +func unmarshalDataChannelStats(b []byte) (DataChannelStats, error) { + var dataChannelStats DataChannelStats + err := json.Unmarshal(b, &dataChannelStats) + if err != nil { + return DataChannelStats{}, fmt.Errorf("unmarshal data channel stats: %w", err) + } + return dataChannelStats, nil +} + // MediaStreamStats contains statistics related to a specific MediaStream. type MediaStreamStats struct { // Timestamp is the timestamp associated with this object. @@ -805,6 +1160,17 @@ type MediaStreamStats struct { TrackIDs []string `json:"trackIds"` } +func (s MediaStreamStats) statsMarker() {} + +func unmarshalStreamStats(b []byte) (MediaStreamStats, error) { + var streamStats MediaStreamStats + err := json.Unmarshal(b, &streamStats) + if err != nil { + return MediaStreamStats{}, fmt.Errorf("unmarshal stream stats: %w", err) + } + return streamStats, nil +} + // AudioSenderStats represents the stats about one audio sender of a PeerConnection // object for which one calls GetStats. // @@ -832,7 +1198,7 @@ type AudioSenderStats struct { // Ended reflects the "ended" state of the track. Ended bool `json:"ended"` - // Kind is either "audio" or "video". This reflects the "kind" attribute of the MediaStreamTrack. + // Kind is "audio" Kind string `json:"kind"` // AudioLevel represents the output audio level of the track. @@ -879,6 +1245,8 @@ type AudioSenderStats struct { TotalSamplesSent uint64 `json:"totalSamplesSent"` } +func (s AudioSenderStats) statsMarker() {} + // SenderAudioTrackAttachmentStats object represents the stats about one attachment // of an audio MediaStreamTrack to the PeerConnection object for which one calls GetStats. // @@ -893,6 +1261,8 @@ type AudioSenderStats struct { // it continues to appear, but with the "ObjectDeleted" member set to true. type SenderAudioTrackAttachmentStats AudioSenderStats +func (s SenderAudioTrackAttachmentStats) statsMarker() {} + // VideoSenderStats represents the stats about one video sender of a PeerConnection // object for which one calls GetStats. // @@ -910,6 +1280,9 @@ type VideoSenderStats struct { // by inspecting the same underlying object. ID string `json:"id"` + // Kind is "video" + Kind string `json:"kind"` + // FramesCaptured represents the total number of frames captured, before encoding, // for this RTPSender (or for this MediaStreamTrack, if type is "track"). For example, // if type is "sender" and this sender's track represents a camera, then this is the @@ -940,6 +1313,8 @@ type VideoSenderStats struct { KeyFramesSent uint32 `json:"keyFramesSent"` } +func (s VideoSenderStats) statsMarker() {} + // SenderVideoTrackAttachmentStats represents the stats about one attachment of a // video MediaStreamTrack to the PeerConnection object for which one calls GetStats. // @@ -954,6 +1329,70 @@ type VideoSenderStats struct { // it continues to appear, but with the "ObjectDeleted" member set to true. type SenderVideoTrackAttachmentStats VideoSenderStats +func (s SenderVideoTrackAttachmentStats) statsMarker() {} + +func unmarshalSenderStats(b []byte) (Stats, error) { + type kindJSON struct { + Kind string `json:"kind"` + } + kindHolder := kindJSON{} + + err := json.Unmarshal(b, &kindHolder) + if err != nil { + return nil, fmt.Errorf("unmarshal json kind: %w", err) + } + + switch MediaKind(kindHolder.Kind) { + case MediaKindAudio: + var senderStats AudioSenderStats + err := json.Unmarshal(b, &senderStats) + if err != nil { + return nil, fmt.Errorf("unmarshal audio sender stats: %w", err) + } + return senderStats, nil + case MediaKindVideo: + var senderStats VideoSenderStats + err := json.Unmarshal(b, &senderStats) + if err != nil { + return nil, fmt.Errorf("unmarshal video sender stats: %w", err) + } + return senderStats, nil + default: + return nil, fmt.Errorf("kind: %w", ErrUnknownType) + } +} + +func unmarshalTrackStats(b []byte) (Stats, error) { + type kindJSON struct { + Kind string `json:"kind"` + } + kindHolder := kindJSON{} + + err := json.Unmarshal(b, &kindHolder) + if err != nil { + return nil, fmt.Errorf("unmarshal json kind: %w", err) + } + + switch MediaKind(kindHolder.Kind) { + case MediaKindAudio: + var trackStats SenderAudioTrackAttachmentStats + err := json.Unmarshal(b, &trackStats) + if err != nil { + return nil, fmt.Errorf("unmarshal audio track stats: %w", err) + } + return trackStats, nil + case MediaKindVideo: + var trackStats SenderVideoTrackAttachmentStats + err := json.Unmarshal(b, &trackStats) + if err != nil { + return nil, fmt.Errorf("unmarshal video track stats: %w", err) + } + return trackStats, nil + default: + return nil, fmt.Errorf("kind: %w", ErrUnknownType) + } +} + // AudioReceiverStats contains audio metrics related to a specific receiver. type AudioReceiverStats struct { // Timestamp is the timestamp associated with this object. @@ -967,6 +1406,9 @@ type AudioReceiverStats struct { // by inspecting the same underlying object. ID string `json:"id"` + // Kind is "audio" + Kind string `json:"kind"` + // AudioLevel represents the output audio level of the track. // // The value is a value between 0..1 (linear), where 1.0 represents 0 dBov, @@ -1038,6 +1480,8 @@ type AudioReceiverStats struct { ConcealmentEvents uint64 `json:"concealmentEvents"` } +func (s AudioReceiverStats) statsMarker() {} + // VideoReceiverStats contains video metrics related to a specific receiver. type VideoReceiverStats struct { // Timestamp is the timestamp associated with this object. @@ -1051,6 +1495,9 @@ type VideoReceiverStats struct { // by inspecting the same underlying object. ID string `json:"id"` + // Kind is "video" + Kind string `json:"kind"` + // FrameWidth represents the width of the last processed frame for this track. // Before the first frame is processed this attribute is missing. FrameWidth uint32 `json:"frameWidth"` @@ -1118,6 +1565,39 @@ type VideoReceiverStats struct { FullFramesLost uint32 `json:"fullFramesLost"` } +func (s VideoReceiverStats) statsMarker() {} + +func unmarshalReceiverStats(b []byte) (Stats, error) { + type kindJSON struct { + Kind string `json:"kind"` + } + kindHolder := kindJSON{} + + err := json.Unmarshal(b, &kindHolder) + if err != nil { + return nil, fmt.Errorf("unmarshal json kind: %w", err) + } + + switch MediaKind(kindHolder.Kind) { + case MediaKindAudio: + var receiverStats AudioReceiverStats + err := json.Unmarshal(b, &receiverStats) + if err != nil { + return nil, fmt.Errorf("unmarshal audio receiver stats: %w", err) + } + return receiverStats, nil + case MediaKindVideo: + var receiverStats VideoReceiverStats + err := json.Unmarshal(b, &receiverStats) + if err != nil { + return nil, fmt.Errorf("unmarshal video receiver stats: %w", err) + } + return receiverStats, nil + default: + return nil, fmt.Errorf("kind: %w", ErrUnknownType) + } +} + // TransportStats contains transport statistics related to the PeerConnection object. type TransportStats struct { // Timestamp is the timestamp associated with this object. @@ -1179,6 +1659,17 @@ type TransportStats struct { SRTPCipher string `json:"srtpCipher"` } +func (s TransportStats) statsMarker() {} + +func unmarshalTransportStats(b []byte) (TransportStats, error) { + var transportStats TransportStats + err := json.Unmarshal(b, &transportStats) + if err != nil { + return TransportStats{}, fmt.Errorf("unmarshal transport stats: %w", err) + } + return transportStats, nil +} + // StatsICECandidatePairState is the state of an ICE candidate pair used in the // ICECandidatePairStats object. type StatsICECandidatePairState string @@ -1363,6 +1854,17 @@ type ICECandidatePairStats struct { ConsentExpiredTimestamp StatsTimestamp `json:"consentExpiredTimestamp"` } +func (s ICECandidatePairStats) statsMarker() {} + +func unmarshalICECandidatePairStats(b []byte) (ICECandidatePairStats, error) { + var iceCandidatePairStats ICECandidatePairStats + err := json.Unmarshal(b, &iceCandidatePairStats) + if err != nil { + return ICECandidatePairStats{}, fmt.Errorf("unmarshal ice candidate pair stats: %w", err) + } + return iceCandidatePairStats, nil +} + // ICECandidateStats contains ICE candidate statistics related to the ICETransport objects. type ICECandidateStats struct { // Timestamp is the timestamp associated with this object. @@ -1390,7 +1892,10 @@ type ICECandidateStats struct { // it's possible that a connection will be bottlenecked by another type of network. // For example, when using Wi-Fi tethering, the networkType of the relevant candidate // would be "wifi", even when the next hop is over a cellular connection. - NetworkType NetworkType `json:"networkType"` + // + // DEPRECATED. Although it may still work in some browsers, the networkType property was deprecated for + // preserving privacy. + NetworkType string `json:"networkType,omitempty"` // IP is the IP address of the candidate, allowing for IPv4 addresses and // IPv6 addresses, but fully qualified domain names (FQDNs) are not allowed. @@ -1426,6 +1931,17 @@ type ICECandidateStats struct { Deleted bool `json:"deleted"` } +func (s ICECandidateStats) statsMarker() {} + +func unmarshalICECandidateStats(b []byte) (ICECandidateStats, error) { + var iceCandidateStats ICECandidateStats + err := json.Unmarshal(b, &iceCandidateStats) + if err != nil { + return ICECandidateStats{}, fmt.Errorf("unmarshal ice candidate stats: %w", err) + } + return iceCandidateStats, nil +} + // CertificateStats contains information about a certificate used by an ICETransport. type CertificateStats struct { // Timestamp is the timestamp associated with this object. @@ -1453,3 +1969,14 @@ type CertificateStats struct { // (i.e. a self-signed certificate), this will not be set. IssuerCertificateID string `json:"issuerCertificateId"` } + +func (s CertificateStats) statsMarker() {} + +func unmarshalCertificateStats(b []byte) (CertificateStats, error) { + var certificateStats CertificateStats + err := json.Unmarshal(b, &certificateStats) + if err != nil { + return CertificateStats{}, fmt.Errorf("unmarshal certificate stats: %w", err) + } + return certificateStats, nil +} diff --git a/stats_go_test.go b/stats_go_test.go index b49b6764f0..1462534acf 100644 --- a/stats_go_test.go +++ b/stats_go_test.go @@ -44,32 +44,982 @@ func TestStatsTimestampTime(t *testing.T) { } } +type statSample struct { + name string + stats Stats + json string +} + +func getStatsSamples() []statSample { + codecStats := CodecStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeCodec, + ID: "COT01_111_minptime=10;useinbandfec=1", + PayloadType: 111, + CodecType: CodecTypeEncode, + TransportID: "T01", + MimeType: "audio/opus", + ClockRate: 48000, + Channels: 2, + SDPFmtpLine: "minptime=10;useinbandfec=1", + Implementation: "libvpx", + } + codecStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "codec", + "id": "COT01_111_minptime=10;useinbandfec=1", + "payloadType": 111, + "codecType": "encode", + "transportId": "T01", + "mimeType": "audio/opus", + "clockRate": 48000, + "channels": 2, + "sdpFmtpLine": "minptime=10;useinbandfec=1", + "implementation": "libvpx" +} +` + inboundRTPStreamStats := InboundRTPStreamStats{ + Timestamp: 1688978831527.718, + ID: "IT01A2184088143", + Type: StatsTypeInboundRTP, + SSRC: 2184088143, + Kind: "audio", + TransportID: "T01", + CodecID: "CIT01_111_minptime=10;useinbandfec=1", + FIRCount: 1, + PLICount: 2, + NACKCount: 3, + SLICount: 4, + QPSum: 5, + PacketsReceived: 6, + PacketsLost: 7, + Jitter: 8, + PacketsDiscarded: 9, + PacketsRepaired: 10, + BurstPacketsLost: 11, + BurstPacketsDiscarded: 12, + BurstLossCount: 13, + BurstDiscardCount: 14, + BurstLossRate: 15, + BurstDiscardRate: 16, + GapLossRate: 17, + GapDiscardRate: 18, + TrackID: "d57dbc4b-484b-4b40-9088-d3150e3a2010", + ReceiverID: "R01", + RemoteID: "ROA2184088143", + FramesDecoded: 17, + LastPacketReceivedTimestamp: 1689668364374.181, + AverageRTCPInterval: 18, + FECPacketsReceived: 19, + BytesReceived: 20, + PacketsFailedDecryption: 21, + PacketsDuplicated: 22, + PerDSCPPacketsReceived: map[string]uint32{ + "123": 23, + }, + } + inboundRTPStreamStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "id": "IT01A2184088143", + "type": "inbound-rtp", + "ssrc": 2184088143, + "kind": "audio", + "transportId": "T01", + "codecId": "CIT01_111_minptime=10;useinbandfec=1", + "firCount": 1, + "pliCount": 2, + "nackCount": 3, + "sliCount": 4, + "qpSum": 5, + "packetsReceived": 6, + "packetsLost": 7, + "jitter": 8, + "packetsDiscarded": 9, + "packetsRepaired": 10, + "burstPacketsLost": 11, + "burstPacketsDiscarded": 12, + "burstLossCount": 13, + "burstDiscardCount": 14, + "burstLossRate": 15, + "burstDiscardRate": 16, + "gapLossRate": 17, + "gapDiscardRate": 18, + "trackId": "d57dbc4b-484b-4b40-9088-d3150e3a2010", + "receiverId": "R01", + "remoteId": "ROA2184088143", + "framesDecoded": 17, + "lastPacketReceivedTimestamp": 1689668364374.181, + "averageRtcpInterval": 18, + "fecPacketsReceived": 19, + "bytesReceived": 20, + "packetsFailedDecryption": 21, + "packetsDuplicated": 22, + "perDscpPacketsReceived": { + "123": 23 + } +} +` + outboundRTPStreamStats := OutboundRTPStreamStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeOutboundRTP, + ID: "OT01A2184088143", + SSRC: 2184088143, + Kind: "audio", + TransportID: "T01", + CodecID: "COT01_111_minptime=10;useinbandfec=1", + FIRCount: 1, + PLICount: 2, + NACKCount: 3, + SLICount: 4, + QPSum: 5, + PacketsSent: 6, + PacketsDiscardedOnSend: 7, + FECPacketsSent: 8, + BytesSent: 9, + BytesDiscardedOnSend: 10, + TrackID: "d57dbc4b-484b-4b40-9088-d3150e3a2010", + SenderID: "S01", + RemoteID: "ROA2184088143", + LastPacketSentTimestamp: 11, + TargetBitrate: 12, + FramesEncoded: 13, + TotalEncodeTime: 14, + AverageRTCPInterval: 15, + QualityLimitationReason: "cpu", + QualityLimitationDurations: map[string]float64{ + "none": 16, + "cpu": 17, + "bandwidth": 18, + "other": 19, + }, + PerDSCPPacketsSent: map[string]uint32{ + "123": 23, + }, + } + outboundRTPStreamStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "outbound-rtp", + "id": "OT01A2184088143", + "ssrc": 2184088143, + "kind": "audio", + "transportId": "T01", + "codecId": "COT01_111_minptime=10;useinbandfec=1", + "firCount": 1, + "pliCount": 2, + "nackCount": 3, + "sliCount": 4, + "qpSum": 5, + "packetsSent": 6, + "packetsDiscardedOnSend": 7, + "fecPacketsSent": 8, + "bytesSent": 9, + "bytesDiscardedOnSend": 10, + "trackId": "d57dbc4b-484b-4b40-9088-d3150e3a2010", + "senderId": "S01", + "remoteId": "ROA2184088143", + "lastPacketSentTimestamp": 11, + "targetBitrate": 12, + "framesEncoded": 13, + "totalEncodeTime": 14, + "averageRtcpInterval": 15, + "qualityLimitationReason": "cpu", + "qualityLimitationDurations": { + "none": 16, + "cpu": 17, + "bandwidth": 18, + "other": 19 + }, + "perDscpPacketsSent": { + "123": 23 + } +} +` + remoteInboundRTPStreamStats := RemoteInboundRTPStreamStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeRemoteInboundRTP, + ID: "RIA2184088143", + SSRC: 2184088143, + Kind: "audio", + TransportID: "T01", + CodecID: "COT01_111_minptime=10;useinbandfec=1", + FIRCount: 1, + PLICount: 2, + NACKCount: 3, + SLICount: 4, + QPSum: 5, + PacketsReceived: 6, + PacketsLost: 7, + Jitter: 8, + PacketsDiscarded: 9, + PacketsRepaired: 10, + BurstPacketsLost: 11, + BurstPacketsDiscarded: 12, + BurstLossCount: 13, + BurstDiscardCount: 14, + BurstLossRate: 15, + BurstDiscardRate: 16, + GapLossRate: 17, + GapDiscardRate: 18, + LocalID: "RIA2184088143", + RoundTripTime: 19, + FractionLost: 20, + } + remoteInboundRTPStreamStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "remote-inbound-rtp", + "id": "RIA2184088143", + "ssrc": 2184088143, + "kind": "audio", + "transportId": "T01", + "codecId": "COT01_111_minptime=10;useinbandfec=1", + "firCount": 1, + "pliCount": 2, + "nackCount": 3, + "sliCount": 4, + "qpSum": 5, + "packetsReceived": 6, + "packetsLost": 7, + "jitter": 8, + "packetsDiscarded": 9, + "packetsRepaired": 10, + "burstPacketsLost": 11, + "burstPacketsDiscarded": 12, + "burstLossCount": 13, + "burstDiscardCount": 14, + "burstLossRate": 15, + "burstDiscardRate": 16, + "gapLossRate": 17, + "gapDiscardRate": 18, + "localId": "RIA2184088143", + "roundTripTime": 19, + "fractionLost": 20 +} +` + remoteOutboundRTPStreamStats := RemoteOutboundRTPStreamStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeRemoteOutboundRTP, + ID: "ROA2184088143", + SSRC: 2184088143, + Kind: "audio", + TransportID: "T01", + CodecID: "CIT01_111_minptime=10;useinbandfec=1", + FIRCount: 1, + PLICount: 2, + NACKCount: 3, + SLICount: 4, + QPSum: 5, + PacketsSent: 1259, + PacketsDiscardedOnSend: 6, + FECPacketsSent: 7, + BytesSent: 92654, + BytesDiscardedOnSend: 8, + LocalID: "IT01A2184088143", + RemoteTimestamp: 1689668361298, + } + remoteOutboundRTPStreamStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "remote-outbound-rtp", + "id": "ROA2184088143", + "ssrc": 2184088143, + "kind": "audio", + "transportId": "T01", + "codecId": "CIT01_111_minptime=10;useinbandfec=1", + "firCount": 1, + "pliCount": 2, + "nackCount": 3, + "sliCount": 4, + "qpSum": 5, + "packetsSent": 1259, + "packetsDiscardedOnSend": 6, + "fecPacketsSent": 7, + "bytesSent": 92654, + "bytesDiscardedOnSend": 8, + "localId": "IT01A2184088143", + "remoteTimestamp": 1689668361298 +} +` + csrcStats := RTPContributingSourceStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeCSRC, + ID: "ROA2184088143", + ContributorSSRC: 2184088143, + InboundRTPStreamID: "IT01A2184088143", + PacketsContributedTo: 5, + AudioLevel: 0.3, + } + csrcStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "csrc", + "id": "ROA2184088143", + "contributorSsrc": 2184088143, + "inboundRtpStreamId": "IT01A2184088143", + "packetsContributedTo": 5, + "audioLevel": 0.3 +} +` + audioSourceStats := AudioSourceStats{ + Timestamp: 1689668364374.479, + Type: StatsTypeMediaSource, + ID: "SA5", + TrackIdentifier: "d57dbc4b-484b-4b40-9088-d3150e3a2010", + Kind: "audio", + AudioLevel: 0.0030518509475997192, + TotalAudioEnergy: 0.0024927631236904358, + TotalSamplesDuration: 28.360000000001634, + EchoReturnLoss: -30, + EchoReturnLossEnhancement: 0.17551203072071075, + DroppedSamplesDuration: 0.1, + DroppedSamplesEvents: 2, + TotalCaptureDelay: 0.3, + TotalSamplesCaptured: 4, + } + audioSourceStatsJSON := ` +{ + "timestamp": 1689668364374.479, + "type": "media-source", + "id": "SA5", + "trackIdentifier": "d57dbc4b-484b-4b40-9088-d3150e3a2010", + "kind": "audio", + "audioLevel": 0.0030518509475997192, + "totalAudioEnergy": 0.0024927631236904358, + "totalSamplesDuration": 28.360000000001634, + "echoReturnLoss": -30, + "echoReturnLossEnhancement": 0.17551203072071075, + "droppedSamplesDuration": 0.1, + "droppedSamplesEvents": 2, + "totalCaptureDelay": 0.3, + "totalSamplesCaptured": 4 +} +` + videoSourceStats := VideoSourceStats{ + Timestamp: 1689668364374.479, + Type: StatsTypeMediaSource, + ID: "SV6", + TrackIdentifier: "d7f11739-d395-42e9-af87-5dfa1cc10ee0", + Kind: "video", + Width: 640, + Height: 480, + Frames: 850, + FramesPerSecond: 30, + } + videoSourceStatsJSON := ` +{ + "timestamp": 1689668364374.479, + "type": "media-source", + "id": "SV6", + "trackIdentifier": "d7f11739-d395-42e9-af87-5dfa1cc10ee0", + "kind": "video", + "width": 640, + "height": 480, + "frames": 850, + "framesPerSecond": 30 +} +` + audioPlayoutStats := AudioPlayoutStats{ + Timestamp: 1689668364374.181, + Type: StatsTypeMediaPlayout, + ID: "AP", + Kind: "audio", + SynthesizedSamplesDuration: 1, + SynthesizedSamplesEvents: 2, + TotalSamplesDuration: 593.5, + TotalPlayoutDelay: 1062194.11536, + TotalSamplesCount: 28488000, + } + audioPlayoutStatsJSON := ` +{ + "timestamp": 1689668364374.181, + "type": "media-playout", + "id": "AP", + "kind": "audio", + "synthesizedSamplesDuration": 1, + "synthesizedSamplesEvents": 2, + "totalSamplesDuration": 593.5, + "totalPlayoutDelay": 1062194.11536, + "totalSamplesCount": 28488000 +} +` + peerConnectionStats := PeerConnectionStats{ + Timestamp: 1688978831527.718, + Type: StatsTypePeerConnection, + ID: "P", + DataChannelsOpened: 1, + DataChannelsClosed: 2, + DataChannelsRequested: 3, + DataChannelsAccepted: 4, + } + peerConnectionStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "peer-connection", + "id": "P", + "dataChannelsOpened": 1, + "dataChannelsClosed": 2, + "dataChannelsRequested": 3, + "dataChannelsAccepted": 4 +} +` + dataChannelStats := DataChannelStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeDataChannel, + ID: "D1", + Label: "display", + Protocol: "protocol", + DataChannelIdentifier: 1, + TransportID: "T1", + State: DataChannelStateOpen, + MessagesSent: 1, + BytesSent: 16, + MessagesReceived: 2, + BytesReceived: 20, + } + dataChannelStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "data-channel", + "id": "D1", + "label": "display", + "protocol": "protocol", + "dataChannelIdentifier": 1, + "transportId": "T1", + "state": "open", + "messagesSent": 1, + "bytesSent": 16, + "messagesReceived": 2, + "bytesReceived": 20 +} +` + streamStats := MediaStreamStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeStream, + ID: "ROA2184088143", + StreamIdentifier: "S1", + TrackIDs: []string{"d57dbc4b-484b-4b40-9088-d3150e3a2010"}, + } + streamStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "stream", + "id": "ROA2184088143", + "streamIdentifier": "S1", + "trackIds": [ + "d57dbc4b-484b-4b40-9088-d3150e3a2010" + ] +} +` + senderVideoTrackAttachmentStats := SenderVideoTrackAttachmentStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeTrack, + ID: "S2", + Kind: "video", + FramesCaptured: 1, + FramesSent: 2, + HugeFramesSent: 3, + KeyFramesSent: 4, + } + senderVideoTrackAttachmentStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "track", + "id": "S2", + "kind": "video", + "framesCaptured": 1, + "framesSent": 2, + "hugeFramesSent": 3, + "keyFramesSent": 4 +} +` + senderAudioTrackAttachmentStats := SenderAudioTrackAttachmentStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeTrack, + ID: "S1", + TrackIdentifier: "audio", + RemoteSource: true, + Ended: true, + Kind: "audio", + AudioLevel: 0.1, + TotalAudioEnergy: 0.2, + VoiceActivityFlag: true, + TotalSamplesDuration: 0.3, + EchoReturnLoss: 0.4, + EchoReturnLossEnhancement: 0.5, + TotalSamplesSent: 200, + } + senderAudioTrackAttachmentStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "track", + "id": "S1", + "trackIdentifier": "audio", + "remoteSource": true, + "ended": true, + "kind": "audio", + "audioLevel": 0.1, + "totalAudioEnergy": 0.2, + "voiceActivityFlag": true, + "totalSamplesDuration": 0.3, + "echoReturnLoss": 0.4, + "echoReturnLossEnhancement": 0.5, + "totalSamplesSent": 200 +} +` + videoSenderStats := VideoSenderStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeSender, + ID: "S2", + Kind: "video", + FramesCaptured: 1, + FramesSent: 2, + HugeFramesSent: 3, + KeyFramesSent: 4, + } + videoSenderStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "sender", + "id": "S2", + "kind": "video", + "framesCaptured": 1, + "framesSent": 2, + "hugeFramesSent": 3, + "keyFramesSent": 4 +} +` + audioSenderStats := AudioSenderStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeSender, + ID: "S1", + TrackIdentifier: "audio", + RemoteSource: true, + Ended: true, + Kind: "audio", + AudioLevel: 0.1, + TotalAudioEnergy: 0.2, + VoiceActivityFlag: true, + TotalSamplesDuration: 0.3, + EchoReturnLoss: 0.4, + EchoReturnLossEnhancement: 0.5, + TotalSamplesSent: 200, + } + audioSenderStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "sender", + "id": "S1", + "trackIdentifier": "audio", + "remoteSource": true, + "ended": true, + "kind": "audio", + "audioLevel": 0.1, + "totalAudioEnergy": 0.2, + "voiceActivityFlag": true, + "totalSamplesDuration": 0.3, + "echoReturnLoss": 0.4, + "echoReturnLossEnhancement": 0.5, + "totalSamplesSent": 200 +} +` + videoReceiverStats := VideoReceiverStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeReceiver, + ID: "ROA2184088143", + Kind: "video", + FrameWidth: 720, + FrameHeight: 480, + FramesPerSecond: 30.0, + EstimatedPlayoutTimestamp: 1688978831527.718, + JitterBufferDelay: 0.1, + JitterBufferEmittedCount: 1, + FramesReceived: 79, + KeyFramesReceived: 10, + FramesDecoded: 10, + FramesDropped: 10, + PartialFramesLost: 5, + FullFramesLost: 5, + } + videoReceiverStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "receiver", + "id": "ROA2184088143", + "kind": "video", + "frameWidth": 720, + "frameHeight": 480, + "framesPerSecond": 30.0, + "estimatedPlayoutTimestamp": 1688978831527.718, + "jitterBufferDelay": 0.1, + "jitterBufferEmittedCount": 1, + "framesReceived": 79, + "keyFramesReceived": 10, + "framesDecoded": 10, + "framesDropped": 10, + "partialFramesLost": 5, + "fullFramesLost": 5 +} +` + audioReceiverStats := AudioReceiverStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeReceiver, + ID: "R1", + Kind: "audio", + AudioLevel: 0.1, + TotalAudioEnergy: 0.2, + VoiceActivityFlag: true, + TotalSamplesDuration: 0.3, + EstimatedPlayoutTimestamp: 1688978831527.718, + JitterBufferDelay: 0.5, + JitterBufferEmittedCount: 6, + TotalSamplesReceived: 7, + ConcealedSamples: 8, + ConcealmentEvents: 9, + } + audioReceiverStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "receiver", + "id": "R1", + "kind": "audio", + "audioLevel": 0.1, + "totalAudioEnergy": 0.2, + "voiceActivityFlag": true, + "totalSamplesDuration": 0.3, + "estimatedPlayoutTimestamp": 1688978831527.718, + "jitterBufferDelay": 0.5, + "jitterBufferEmittedCount": 6, + "totalSamplesReceived": 7, + "concealedSamples": 8, + "concealmentEvents": 9 +} +` + transportStats := TransportStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeTransport, + ID: "T01", + PacketsSent: 60, + PacketsReceived: 8, + BytesSent: 6517, + BytesReceived: 1159, + RTCPTransportStatsID: "T01", + ICERole: ICERoleControlling, + DTLSState: DTLSTransportStateConnected, + SelectedCandidatePairID: "CPxIhBDNnT_sPDhy1TB", + LocalCertificateID: "CFF4:4F:C4:C7:F3:31:6C:B9:D5:AD:19:64:05:9F:2F:E9:00:70:56:1E:BA:92:29:3A:08:CE:1B:27:CF:2D:AB:24", + RemoteCertificateID: "CF62:AF:88:F7:F3:0F:D6:C4:93:91:1E:AD:52:F0:A4:12:04:F9:48:E7:06:16:BA:A3:86:26:8F:1E:38:1C:48:49", + DTLSCipher: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + SRTPCipher: "AES_CM_128_HMAC_SHA1_80", + } + transportStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "transport", + "id": "T01", + "packetsSent": 60, + "packetsReceived": 8, + "bytesSent": 6517, + "bytesReceived": 1159, + "rtcpTransportStatsId": "T01", + "iceRole": "controlling", + "dtlsState": "connected", + "selectedCandidatePairId": "CPxIhBDNnT_sPDhy1TB", + "localCertificateId": "CFF4:4F:C4:C7:F3:31:6C:B9:D5:AD:19:64:05:9F:2F:E9:00:70:56:1E:BA:92:29:3A:08:CE:1B:27:CF:2D:AB:24", + "remoteCertificateId": "CF62:AF:88:F7:F3:0F:D6:C4:93:91:1E:AD:52:F0:A4:12:04:F9:48:E7:06:16:BA:A3:86:26:8F:1E:38:1C:48:49", + "dtlsCipher": "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", + "srtpCipher": "AES_CM_128_HMAC_SHA1_80" +} +` + iceCandidatePairStats := ICECandidatePairStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeCandidatePair, + ID: "CPxIhBDNnT_LlMJOnBv", + TransportID: "T01", + LocalCandidateID: "IxIhBDNnT", + RemoteCandidateID: "ILlMJOnBv", + State: "waiting", + Nominated: true, + PacketsSent: 1, + PacketsReceived: 2, + BytesSent: 3, + BytesReceived: 4, + LastPacketSentTimestamp: 5, + LastPacketReceivedTimestamp: 6, + FirstRequestTimestamp: 7, + LastRequestTimestamp: 8, + LastResponseTimestamp: 9, + TotalRoundTripTime: 10, + CurrentRoundTripTime: 11, + AvailableOutgoingBitrate: 12, + AvailableIncomingBitrate: 13, + CircuitBreakerTriggerCount: 14, + RequestsReceived: 15, + RequestsSent: 16, + ResponsesReceived: 17, + ResponsesSent: 18, + RetransmissionsReceived: 19, + RetransmissionsSent: 20, + ConsentRequestsSent: 21, + ConsentExpiredTimestamp: 22, + } + iceCandidatePairStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "candidate-pair", + "id": "CPxIhBDNnT_LlMJOnBv", + "transportId": "T01", + "localCandidateId": "IxIhBDNnT", + "remoteCandidateId": "ILlMJOnBv", + "state": "waiting", + "nominated": true, + "packetsSent": 1, + "packetsReceived": 2, + "bytesSent": 3, + "bytesReceived": 4, + "lastPacketSentTimestamp": 5, + "lastPacketReceivedTimestamp": 6, + "firstRequestTimestamp": 7, + "lastRequestTimestamp": 8, + "lastResponseTimestamp": 9, + "totalRoundTripTime": 10, + "currentRoundTripTime": 11, + "availableOutgoingBitrate": 12, + "availableIncomingBitrate": 13, + "circuitBreakerTriggerCount": 14, + "requestsReceived": 15, + "requestsSent": 16, + "responsesReceived": 17, + "responsesSent": 18, + "retransmissionsReceived": 19, + "retransmissionsSent": 20, + "consentRequestsSent": 21, + "consentExpiredTimestamp": 22 +} +` + localIceCandidateStats := ICECandidateStats{ + Timestamp: 1688978831527.718, + Type: StatsTypeLocalCandidate, + ID: "ILO8S8KYr", + TransportID: "T01", + NetworkType: "wifi", + IP: "192.168.0.36", + Port: 65400, + Protocol: "udp", + CandidateType: ICECandidateTypeHost, + Priority: 2122260223, + URL: "example.com", + RelayProtocol: "tcp", + Deleted: true, + } + localIceCandidateStatsJSON := ` +{ + "timestamp": 1688978831527.718, + "type": "local-candidate", + "id": "ILO8S8KYr", + "transportId": "T01", + "networkType": "wifi", + "ip": "192.168.0.36", + "port": 65400, + "protocol": "udp", + "candidateType": "host", + "priority": 2122260223, + "url": "example.com", + "relayProtocol": "tcp", + "deleted": true +} +` + remoteIceCandidateStats := ICECandidateStats{ + Timestamp: 1689668364374.181, + Type: StatsTypeRemoteCandidate, + ID: "IGPGeswsH", + TransportID: "T01", + IP: "10.213.237.226", + Port: 50618, + Protocol: "udp", + CandidateType: ICECandidateTypeHost, + Priority: 2122194687, + URL: "example.com", + RelayProtocol: "tcp", + Deleted: true, + } + remoteIceCandidateStatsJSON := ` +{ + "timestamp": 1689668364374.181, + "type": "remote-candidate", + "id": "IGPGeswsH", + "transportId": "T01", + "ip": "10.213.237.226", + "port": 50618, + "protocol": "udp", + "candidateType": "host", + "priority": 2122194687, + "url": "example.com", + "relayProtocol": "tcp", + "deleted": true +} +` + certificateStats := CertificateStats{ + Timestamp: 1689668364374.479, + Type: StatsTypeCertificate, + ID: "CF23:AB:FA:0B:0E:DF:12:34:D3:6C:EA:83:43:BD:79:39:87:39:11:49:41:8A:63:0E:17:B1:3F:94:FA:E3:62:20", + Fingerprint: "23:AB:FA:0B:0E:DF:12:34:D3:6C:EA:83:43:BD:79:39:87:39:11:49:41:8A:63:0E:17:B1:3F:94:FA:E3:62:20", + FingerprintAlgorithm: "sha-256", + Base64Certificate: "MIIBFjCBvKADAgECAggAwlrxojpmgTAKBggqhkjOPQQDAjARMQ8wDQYDVQQDDAZXZWJSVEMwHhcNMjMwNzE3MDgxODU2WhcNMjMwODE3MDgxODU2WjARMQ8wDQYDVQQDDAZXZWJSVEMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARKETeS9qNGe3ltwp+q2KgsYWsJLFCJGap4L2aa862sPijHeuzLgO2bju/mosJN0Li7mXhuKBOsCkCMU7vZHVVVMAoGCCqGSM49BAMCA0kAMEYCIQDXyuyMMrgzd+w3c4h3vPn9AzLcf9CHVHRGYyy5ReI/hgIhALkXfaZ96TQRf5FI2mBJJUX9O/q4Poe3wNZxxWeDcYN+", + IssuerCertificateID: "CF62:AF:88:F7:F3:0F:D6:C4:93:91:1E:AD:52:F0:A4:12:04:F9:48:E7:06:16:BA:A3:86:26:8F:1E:38:1C:48:49", + } + certificateStatsJSON := ` +{ + "timestamp": 1689668364374.479, + "type": "certificate", + "id": "CF23:AB:FA:0B:0E:DF:12:34:D3:6C:EA:83:43:BD:79:39:87:39:11:49:41:8A:63:0E:17:B1:3F:94:FA:E3:62:20", + "fingerprint": "23:AB:FA:0B:0E:DF:12:34:D3:6C:EA:83:43:BD:79:39:87:39:11:49:41:8A:63:0E:17:B1:3F:94:FA:E3:62:20", + "fingerprintAlgorithm": "sha-256", + "base64Certificate": "MIIBFjCBvKADAgECAggAwlrxojpmgTAKBggqhkjOPQQDAjARMQ8wDQYDVQQDDAZXZWJSVEMwHhcNMjMwNzE3MDgxODU2WhcNMjMwODE3MDgxODU2WjARMQ8wDQYDVQQDDAZXZWJSVEMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARKETeS9qNGe3ltwp+q2KgsYWsJLFCJGap4L2aa862sPijHeuzLgO2bju/mosJN0Li7mXhuKBOsCkCMU7vZHVVVMAoGCCqGSM49BAMCA0kAMEYCIQDXyuyMMrgzd+w3c4h3vPn9AzLcf9CHVHRGYyy5ReI/hgIhALkXfaZ96TQRf5FI2mBJJUX9O/q4Poe3wNZxxWeDcYN+", + "issuerCertificateId": "CF62:AF:88:F7:F3:0F:D6:C4:93:91:1E:AD:52:F0:A4:12:04:F9:48:E7:06:16:BA:A3:86:26:8F:1E:38:1C:48:49" +} +` + + return []statSample{ + { + name: "codec_stats", + stats: codecStats, + json: codecStatsJSON, + }, + { + name: "inbound_rtp_stream_stats", + stats: inboundRTPStreamStats, + json: inboundRTPStreamStatsJSON, + }, + { + name: "outbound_rtp_stream_stats", + stats: outboundRTPStreamStats, + json: outboundRTPStreamStatsJSON, + }, + { + name: "remote_inbound_rtp_stream_stats", + stats: remoteInboundRTPStreamStats, + json: remoteInboundRTPStreamStatsJSON, + }, + { + name: "remote_outbound_rtp_stream_stats", + stats: remoteOutboundRTPStreamStats, + json: remoteOutboundRTPStreamStatsJSON, + }, + { + name: "rtp_contributing_source_stats", + stats: csrcStats, + json: csrcStatsJSON, + }, + { + name: "audio_source_stats", + stats: audioSourceStats, + json: audioSourceStatsJSON, + }, + { + name: "video_source_stats", + stats: videoSourceStats, + json: videoSourceStatsJSON, + }, + { + name: "audio_playout_stats", + stats: audioPlayoutStats, + json: audioPlayoutStatsJSON, + }, + { + name: "peer_connection_stats", + stats: peerConnectionStats, + json: peerConnectionStatsJSON, + }, + { + name: "data_channel_stats", + stats: dataChannelStats, + json: dataChannelStatsJSON, + }, + { + name: "media_stream_stats", + stats: streamStats, + json: streamStatsJSON, + }, + { + name: "sender_video_track_stats", + stats: senderVideoTrackAttachmentStats, + json: senderVideoTrackAttachmentStatsJSON, + }, + { + name: "sender_audio_track_stats", + stats: senderAudioTrackAttachmentStats, + json: senderAudioTrackAttachmentStatsJSON, + }, + { + name: "receiver_video_track_stats", + stats: videoSenderStats, + json: videoSenderStatsJSON, + }, + { + name: "receiver_audio_track_stats", + stats: audioSenderStats, + json: audioSenderStatsJSON, + }, + { + name: "receiver_video_track_stats", + stats: videoReceiverStats, + json: videoReceiverStatsJSON, + }, + { + name: "receiver_audio_track_stats", + stats: audioReceiverStats, + json: audioReceiverStatsJSON, + }, + { + name: "transport_stats", + stats: transportStats, + json: transportStatsJSON, + }, + { + name: "ice_candidate_pair_stats", + stats: iceCandidatePairStats, + json: iceCandidatePairStatsJSON, + }, + { + name: "local_ice_candidate_stats", + stats: localIceCandidateStats, + json: localIceCandidateStatsJSON, + }, + { + name: "remote_ice_candidate_stats", + stats: remoteIceCandidateStats, + json: remoteIceCandidateStatsJSON, + }, + { + name: "certificate_stats", + stats: certificateStats, + json: certificateStatsJSON, + }, + } +} + func TestStatsMarshal(t *testing.T) { - for _, test := range []Stats{ - AudioReceiverStats{}, - AudioSenderStats{}, - CertificateStats{}, - CodecStats{}, - DataChannelStats{}, - ICECandidatePairStats{}, - ICECandidateStats{}, - InboundRTPStreamStats{}, - MediaStreamStats{}, - OutboundRTPStreamStats{}, - PeerConnectionStats{}, - RemoteInboundRTPStreamStats{}, - RemoteOutboundRTPStreamStats{}, - RTPContributingSourceStats{}, - SenderAudioTrackAttachmentStats{}, - SenderVideoTrackAttachmentStats{}, - TransportStats{}, - VideoReceiverStats{}, - VideoSenderStats{}, - } { - _, err := json.Marshal(test) - if err != nil { - t.Fatal(err) - } + for _, test := range getStatsSamples() { + t.Run(test.name+"_marshal", func(t *testing.T) { + actualJSON, err := json.Marshal(test.stats) + require.NoError(t, err) + + assert.JSONEq(t, test.json, string(actualJSON)) + }) + } +} + +func TestStatsUnmarshal(t *testing.T) { + for _, test := range getStatsSamples() { + t.Run(test.name+"_unmarshal", func(t *testing.T) { + actualStats, err := UnmarshalStatsJSON([]byte(test.json)) + require.NoError(t, err) + + assert.Equal(t, test.stats, actualStats) + }) } }