Repository: pion/webrtc
Pull request #2513 (2023-07-20 11:19:55)

Stats: improve JSON support, add missing structs
Hi there! We are delighted to have the opportunity to utilize pion's webrtc-stats implementation for unmarshalling JSON stats at our backend. I have put in some effort to make it feasible and would love to contribute it.

## Description:
- 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://www.w3.org/TR/2018/WD-webrtc-stats-20180627/#rtcnetworktype-enum

AbsoluteNikola: Amazing feature. We really need this
Sean-Der: This looks great @aalekseevx lets get it merged! 

I will review tonight. If you don’t hear from me please ping here and on https://pion.ly/slack 

Lots of stuff going on sometimes get pulled away :/
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pion/webrtc/pull/2513?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion) Report
Patch coverage has no change and project coverage change: **`-13.76`** :warning:
> Comparison is base [(`3cddada`)](https://app.codecov.io/gh/pion/webrtc/commit/3cddada6b449b164d860ca6ab4787c48910835fd?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion) 77.71% compared to head [(`111b1d3`)](https://app.codecov.io/gh/pion/webrtc/pull/2513?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion) 63.95%.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           master    #2513       +/-   ##
===========================================
- Coverage   77.71%   63.95%   -13.76%     
===========================================
  Files          87       64       -23     
  Lines        9348     3679     -5669     
===========================================
- Hits         7265     2353     -4912     
+ Misses       1652     1198      -454     
+ Partials      431      128      -303     
```

| Flag | Coverage Δ | |
|---|---|---|
| go | `?` | |
| wasm | `63.95% <0.00%> (-6.20%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://app.codecov.io/gh/pion/webrtc/pull/2513?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion) | Coverage Δ | |
|---|---|---|
| [datachannelstate.go](https://app.codecov.io/gh/pion/webrtc/pull/2513?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-ZGF0YWNoYW5uZWxzdGF0ZS5nbw==) | `82.75% <0.00%> (-17.25%)` | :arrow_down: |
| [dtlstransportstate.go](https://app.codecov.io/gh/pion/webrtc/pull/2513?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-ZHRsc3RyYW5zcG9ydHN0YXRlLmdv) | `84.84% <0.00%> (-15.16%)` | :arrow_down: |
| [icecandidatetype.go](https://app.codecov.io/gh/pion/webrtc/pull/2513?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-aWNlY2FuZGlkYXRldHlwZS5nbw==) | `54.54% <0.00%> (-24.41%)` | :arrow_down: |
| [icerole.go](https://app.codecov.io/gh/pion/webrtc/pull/2513?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-aWNlcm9sZS5nbw==) | `76.19% <0.00%> (-23.81%)` | :arrow_down: |
| [stats.go](https://app.codecov.io/gh/pion/webrtc/pull/2513?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-c3RhdHMuZ28=) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |

... and [45 files with indirect coverage changes](https://app.codecov.io/gh/pion/webrtc/pull/2513/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion)


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pion/webrtc/pull/2513?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion).

aalekseevx: @Sean-Der, hi! I made some fixes for CI checks, do you have some time to complete the review now?
aalekseevx: Any updates?
Sean-Der: Fantastic work @aalekseevx! sorry I didn't get a chance to review sooner

* Can we drop the dependency? Outside of tests we have 0 3rd party dependencies, would be nice to keep that :)

* Can you move the testdata into the files that use them? I don't feel strongly either way, I just would like to follow the established pattern.

Thank you so much for doing this work. It is a very important contribution. Sorry I did't respond sooner, personal life has just kept me busy. thank you!
Sean-Der: @aalekseevx nope you did a great job! I will merge and tag today.

thank you for pinging me. This is super important contribution. Sorry I have been distracted with OBS dev.

Patch:
diff --git a/AUTHORS.txt b/AUTHORS.txt
index 6b50c707..dcfc1193 100644
--- a/AUTHORS.txt
+++ b/AUTHORS.txt
@@ -12,6 +12,7 @@ Adrian Cable <adrian.cable@gmail.com>
 adwpc <adwpc@hotmail.com>
 aggresss <aggresss@163.com>
 akil <akil@everycrave.me>
+Aleksandr Alekseev <alekseev-dev@yandex-team.ru>
 Aleksandr Razumov <ar@gortc.io>
 aler9 <46489434+aler9@users.noreply.github.com>
 Alex Browne <stephenalexbrowne@gmail.com>
diff --git a/datachannelstate.go b/datachannelstate.go
index b2a85aae..55dc916e 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 f986e22c..573dd4bf 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 a274c45d..5bef825b 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 03762cf7..4265c5da 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 99268bac..a46b4559 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 5a43cb72..ddf6a537 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
+}


Test patch:
diff --git a/stats_go_test.go b/stats_go_test.go
index b49b6764..1462534a 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)
+		})
 	}
 }