Skip to content

Commit

Permalink
Gather player metrics (#912)
Browse files Browse the repository at this point in the history
Co-authored-by: Walid Kayhal <[email protected]>
  • Loading branch information
defagos and waliid authored Jun 17, 2024
1 parent 05f65fc commit a599034
Show file tree
Hide file tree
Showing 28 changed files with 1,087 additions and 24 deletions.
6 changes: 3 additions & 3 deletions Demo/Sources/Model/Template.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ private let kUnifiedStreamingImageUrl2 = URL("https://website-storage.unified-st
// Unified Streaming streams are found at https://demo.unified-streaming.com/k8s/features/stable/#!/hls
enum URLTemplate {
static let onDemandVideoHLS = Template(
title: "Switzerland says sorry! The fondue invasion",
title: "Sacha part à la rencontre d'univers atypiques",
subtitle: "VOD - HLS",
imageUrl: "https://cdn.prod.swi-services.ch/video-delivery/images/14e4562f-725d-4e41-a200-7fcaa77df2fe/5rwf1Bq_m3GC5secOZcIcgbbrbZPf4nI/16x9",
type: .url("https://swi-vod.akamaized.net/videoJson/47603186/master.m3u8")
imageUrl: "https://www.rts.ch/2024/06/13/11/34/14970435.image/16x9",
type: .url("https://rts-vod-amd.akamaized.net/ww/14970442/7510ee63-05a4-3d48-8d26-1f1b3a82f6be/master.m3u8")
)
static let shortOnDemandVideoHLS = Template(
title: "Des violents orages ont touché Ajaccio, chef-lieu de la Corse, jeudi",
Expand Down
6 changes: 3 additions & 3 deletions Sources/Analytics/Analytics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ public class Analytics {
/// Tracks a page view.
///
/// - Parameters:
/// - comScore: comScore page view data.
/// - commandersAct: Commanders Act page view data.
/// - comScorePageView: comScore page view data.
/// - commandersActPageView: Commanders Act page view data.
public func trackPageView(
comScore comScorePageView: ComScorePageView,
commandersAct commandersActPageView: CommandersActPageView
Expand All @@ -102,7 +102,7 @@ public class Analytics {

/// Sends an event.
///
/// - Parameter commandersAct: Commanders Act event data
/// - Parameter commandersActEvent: Commanders Act event data
public func sendEvent(commandersAct commandersActEvent: CommandersActEvent) {
commandersActService.sendEvent(
commandersActEvent.merging(globals: dataSource?.commandersActGlobals)
Expand Down
2 changes: 1 addition & 1 deletion Sources/Core/View.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public extension View {
/// - publisher: The publisher to subscribe to.
/// - keyPath: The key path to extract.
/// - binding: The binding to which values must be assigned.
/// - Returns: A view that fills the given binding when the `publisher` emits an event.
/// - Returns: A view that fills the given binding when the `publisher` emits an event.
func onReceive<P, T>(
_ publisher: P,
assign keyPath: KeyPath<P.Output, T>,
Expand Down
4 changes: 1 addition & 3 deletions Sources/Player/Extensions/SCNQuaternion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,7 @@ public func SCNQuaternionWithAngleAndAxis(_ radians: Float, _ x: Float, _ y: Flo

/// Returns a quaternion matching the provided CoreMotion attitude.
///
/// - Parameters:
/// - attitude: The current device orientation in space, as returned by a `CMMotionManager` instance.
/// - interfaceOrientation: The interface orientation.
/// - Parameter attitude: The current device orientation in space, as returned by a `CMMotionManager` instance.
/// - Returns: The quaternion.
///
/// Unlike [`CMAttitude/quaternion`](https://developer.apple.com/documentation/coremotion/cmattitude/1616025-quaternion)
Expand Down
33 changes: 33 additions & 0 deletions Sources/Player/Metrics/AccessLog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// Copyright (c) SRG SSR. All rights reserved.
//
// License information is available from the LICENSE file.
//

import AVFoundation

struct AccessLog {
let closedEvents: [AccessLogEvent]
let openEvent: AccessLogEvent?

init(events: [AccessLogEvent?], after date: Date?) {
closedEvents = Array(events.prefix(max(events.count - 1, 0))).compactMap { event in
Self.event(event, after: date)
}
if let lastEvent = events.last {
openEvent = Self.event(lastEvent, after: date)
}
else {
openEvent = nil
}
}

init(_ log: AVPlayerItemAccessLog, after date: Date?) {
self.init(events: log.events.map { .init($0) }, after: date)
}

static func event(_ event: AccessLogEvent?, after date: Date?) -> AccessLogEvent? {
guard let date, let event else { return event }
return event.playbackStartDate > date ? event : nil
}
}
136 changes: 136 additions & 0 deletions Sources/Player/Metrics/AccessLogEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
//
// Copyright (c) SRG SSR. All rights reserved.
//
// License information is available from the LICENSE file.
//

import AVFoundation

struct AccessLogEvent: Equatable {
let playbackStartDate: Date

// MARK: Optional information

let uri: String?
let serverAddress: String?
let playbackSessionId: String?
let playbackStartOffset: TimeInterval?
let playbackType: String?
let startupTime: TimeInterval?
let observedBitrateStandardDeviation: Double?
let indicatedBitrate: Double?
let observedBitrate: Double?
let averageAudioBitrate: Double?
let averageVideoBitrate: Double?
let indicatedAverageBitrate: Double?

// MARK: Additive information

let numberOfServerAddressChanges: Int
let mediaRequestsWWAN: Int
let transferDuration: TimeInterval
let numberOfBytesTransferred: Int64
let numberOfMediaRequests: Int
let playbackDuration: TimeInterval
let numberOfDroppedVideoFrames: Int
let numberOfStalls: Int
let segmentsDownloadedDuration: TimeInterval
let downloadOverdue: Int
let switchBitrate: Double

init?(
playbackStartDate: Date?,
uri: String?,
serverAddress: String?,
playbackSessionId: String?,
playbackStartOffset: TimeInterval,
playbackType: String?,
startupTime: TimeInterval,
observedBitrateStandardDeviation: Double,
indicatedBitrate: Double,
observedBitrate: Double,
averageAudioBitrate: Double,
averageVideoBitrate: Double,
indicatedAverageBitrate: Double,
numberOfServerAddressChanges: Int,
mediaRequestsWWAN: Int,
transferDuration: TimeInterval,
numberOfBytesTransferred: Int64,
numberOfMediaRequests: Int,
playbackDuration: TimeInterval,
numberOfDroppedVideoFrames: Int,
numberOfStalls: Int,
segmentsDownloadedDuration: TimeInterval,
downloadOverdue: Int,
switchBitrate: Double
) {
guard let playbackStartDate else { return nil }
self.playbackStartDate = playbackStartDate

self.uri = uri
self.serverAddress = serverAddress
self.playbackSessionId = playbackSessionId
self.playbackStartOffset = Self.optional(playbackStartOffset)
self.playbackType = playbackType
self.startupTime = Self.optional(startupTime)
self.observedBitrateStandardDeviation = Self.optional(observedBitrateStandardDeviation)
self.indicatedBitrate = Self.optional(indicatedBitrate)
self.observedBitrate = Self.optional(observedBitrate)
self.averageAudioBitrate = Self.optional(averageAudioBitrate)
self.averageVideoBitrate = Self.optional(averageVideoBitrate)
self.indicatedAverageBitrate = Self.optional(indicatedAverageBitrate)

self.numberOfServerAddressChanges = Self.nonNegative(numberOfServerAddressChanges)
self.mediaRequestsWWAN = Self.nonNegative(mediaRequestsWWAN)
self.transferDuration = Self.nonNegative(transferDuration)
self.numberOfBytesTransferred = Self.nonNegative(numberOfBytesTransferred)
self.numberOfMediaRequests = Self.nonNegative(numberOfMediaRequests)
self.playbackDuration = Self.nonNegative(playbackDuration)
self.numberOfDroppedVideoFrames = Self.nonNegative(numberOfDroppedVideoFrames)
self.numberOfStalls = Self.nonNegative(numberOfStalls)
self.segmentsDownloadedDuration = Self.nonNegative(segmentsDownloadedDuration)
self.downloadOverdue = Self.nonNegative(downloadOverdue)
self.switchBitrate = Self.nonNegative(switchBitrate)
}
}

extension AccessLogEvent {
init?(_ event: AVPlayerItemAccessLogEvent) {
self.init(
playbackStartDate: event.playbackStartDate,
uri: event.uri,
serverAddress: event.serverAddress,
playbackSessionId: event.playbackSessionID,
playbackStartOffset: event.playbackStartOffset,
playbackType: event.playbackType,
startupTime: event.startupTime,
observedBitrateStandardDeviation: event.observedBitrateStandardDeviation,
indicatedBitrate: event.indicatedBitrate,
observedBitrate: event.observedBitrate,
averageAudioBitrate: event.averageAudioBitrate,
averageVideoBitrate: event.averageVideoBitrate,
indicatedAverageBitrate: event.indicatedAverageBitrate,
numberOfServerAddressChanges: event.numberOfServerAddressChanges,
mediaRequestsWWAN: event.mediaRequestsWWAN,
transferDuration: event.transferDuration,
numberOfBytesTransferred: event.numberOfBytesTransferred,
numberOfMediaRequests: event.numberOfMediaRequests,
playbackDuration: event.durationWatched,
numberOfDroppedVideoFrames: event.numberOfDroppedVideoFrames,
numberOfStalls: event.numberOfStalls,
segmentsDownloadedDuration: event.segmentsDownloadedDuration,
downloadOverdue: event.downloadOverdue,
switchBitrate: event.switchBitrate
)
}
}

extension AccessLogEvent {
static func nonNegative<T>(_ value: T) -> T where T: Comparable & SignedNumeric {
max(value, .zero)
}

static func optional<T>(_ value: T) -> T? where T: Comparable & SignedNumeric {
value < .zero ? nil : value
}
}
71 changes: 71 additions & 0 deletions Sources/Player/Metrics/Metrics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// Copyright (c) SRG SSR. All rights reserved.
//
// License information is available from the LICENSE file.
//

import CoreMedia
import Foundation

/// An object used to capture metrics associated with a player.
public struct Metrics: Equatable {
/// The date and time at which playback began.
public let playbackStartDate: Date

/// The player time at the time metrics were captured.
public let time: CMTime

// MARK: Server information

/// The URI of the playback item.
public let uri: String?

/// The IP address of the server that was the source of the last delivered media segment.
public let serverAddress: String?

// MARK: Playback-related information

/// A GUID that identifies the current playback session.
public let playbackSessionId: String?

/// The offset, in seconds, in the playlist where the last uninterrupted period of playback began.
public let playbackStartOffset: TimeInterval?

/// The playback type.
public let playbackType: String?

/// The accumulated duration, in seconds, until the player item is ready to play.
public let startupTime: TimeInterval?

// MARK: Bitrate information

/// The standard deviation of the observed segment download bit rates.
///
/// Measures actual network download performance.
public let observedBitrateStandardDeviation: Double?

/// The throughput, in bits per second, required to play the stream, as advertised by the server.
public let indicatedBitrate: Double?

/// The empirical throughput, in bits per second, across all media downloaded.
///
/// Measures actual network download performance. Provides a bandwidth estimate.
public let observedBitrate: Double?

/// The audio track’s average bit rate, in bits per second.
public let averageAudioBitrate: Double?

/// The video track’s average bit rate, in bits per second.
public let averageVideoBitrate: Double?

/// The average throughput, in bits per second, required to play the stream, as advertised by the server.
public let indicatedAverageBitrate: Double?

// MARK: Values

/// The associated increment.
public let increment: MetricsValues

/// The associated total.
public let total: MetricsValues
}
75 changes: 75 additions & 0 deletions Sources/Player/Metrics/MetricsState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//
// Copyright (c) SRG SSR. All rights reserved.
//
// License information is available from the LICENSE file.
//

import AVFoundation

struct MetricsState: Equatable {
static let empty = Self(time: .invalid, event: nil, total: .zero, cache: .empty)

private let time: CMTime
private let event: AccessLogEvent?
private let total: MetricsValues
let cache: Cache

func updated(with log: AccessLog, at time: CMTime) -> Self? {
let cache = cache.updated(with: log)
if let openEvent = log.openEvent {
return .init(time: time, event: openEvent, total: cache.total + .values(from: openEvent), cache: cache)
}
else if let lastClosedEvent = log.closedEvents.last {
return .init(time: time, event: lastClosedEvent, total: cache.total, cache: cache)
}
else {
return nil
}
}

func updated(with log: AVPlayerItemAccessLog?, at time: CMTime) -> Self? {
guard let log else { return nil }
return updated(with: .init(log, after: cache.date), at: time)
}

func metrics(from state: Self) -> Metrics? {
guard let event else { return nil }
return .init(
playbackStartDate: event.playbackStartDate,
time: time,
uri: event.uri,
serverAddress: event.serverAddress,
playbackSessionId: event.playbackSessionId,
playbackStartOffset: event.playbackStartOffset,
playbackType: event.playbackType,
startupTime: event.startupTime,
observedBitrateStandardDeviation: event.observedBitrateStandardDeviation,
indicatedBitrate: event.indicatedBitrate,
observedBitrate: event.observedBitrate,
averageAudioBitrate: event.averageAudioBitrate,
averageVideoBitrate: event.averageVideoBitrate,
indicatedAverageBitrate: event.indicatedAverageBitrate,
increment: total - state.total,
total: total
)
}
}

extension MetricsState {
struct Cache: Equatable {
static let empty = Self(date: nil, total: .zero)

let date: Date?
let total: MetricsValues

func updated(with log: AccessLog) -> Self {
guard let lastEvent = log.closedEvents.last else { return self }
return .init(
date: lastEvent.playbackStartDate,
total: log.closedEvents.reduce(total) { initial, next in
initial + .values(from: next)
}
)
}
}
}
Loading

0 comments on commit a599034

Please sign in to comment.