Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions DATAMODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ QOE_AGGREGATE events carry all standard VideoAction attributes (viewId, viewSess
| rebufferingRatio | Rebuffering time as a percentage of total playtime: (totalRebufferingTime / totalPlaytime) * 100. |
| hadStartupError | True if an error occurred before content start. |
| hadPlaybackError | True if an error occurred after content start. |
| avgDownloadRate | Mean of every observed network download throughput sample during content (bps). Consecutive duplicate samples are de-duped to avoid bias from idle windows where the player reports the same stale value. Omitted when no sample was observed. |
| minDownloadRate | Minimum observed network download throughput sample during content (bps). Omitted when no sample was observed. |
| maxDownloadRate | Maximum observed network download throughput sample during content (bps). Omitted when no sample was observed. |
| totalSwitchUps | Count of content rendition changes whose new bitrate was higher than the previous rendition. |
| totalSwitchDowns | Count of content rendition changes whose new bitrate was lower than the previous rendition. |
| totalPauseTime | Total content pause duration (ms). Includes any currently-open pause at emit time, so mid-pause harvests report a growing value rather than a stale one. |
| totalRenditions | Count of distinct content renditions observed during the session, keyed by `contentRenditionWidth × contentRenditionHeight`. Pixel-area keying is robust to streams that label distinct resolution variants with the same bitrate. |
| qoeAggregateVersion | Schema version of the QOE_AGGREGATE event (currently `1.1.0`). Bump when the KPI set or semantics change. |

### VideoAdAction

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,12 @@ - (void)sendEnd {
[self sendResume];
// Send END
[super sendEnd];


// Reset the rendition-shift baseline so the NEXT view's first rendition is
// treated as a first observation
self.lastRenditionWidth = 0;
self.lastRenditionHeight = 0;

AV_LOG(@"(AVPlayerTracker) sendEnd");
}

Expand Down
9 changes: 9 additions & 0 deletions NewRelicVideoCore/NewRelicVideoCore/Model/NRQoEAggregator.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ NS_ASSUME_NONNULL_BEGIN
*/
- (void)processAction:(NSString *)action attributes:(NSDictionary *)attributes isPlaying:(BOOL)isPlaying;

/**
As above, but marks whether this content event occurred during an ad break.
A CONTENT_PAUSE delivered with adBreakActive == YES is the player paused for an
ad, not a user pause, so it is excluded from totalPauseTime.

@param adBreakActive Whether an ad break is currently in progress (from the linked ad tracker).
*/
- (void)processAction:(NSString *)action attributes:(NSDictionary *)attributes isPlaying:(BOOL)isPlaying adBreakActive:(BOOL)adBreakActive;

/**
Generate the QoE aggregate attributes dictionary.
Includes the current in-progress bitrate segment in the average calculation
Expand Down
164 changes: 164 additions & 0 deletions NewRelicVideoCore/NewRelicVideoCore/Model/NRQoEAggregator.m
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

@interface NRQoEAggregator () {
long _totalPreRollAdTime; // Instance variable for startup calculation
BOOL _adBreakActive; // YES while the current content event occurs during an ad break
}

// --- Lifecycle flags ---
Expand Down Expand Up @@ -50,6 +51,24 @@ @interface NRQoEAggregator () {
// Used to compute rebufferingRatio = (totalRebufferingTime / lastTotalPlaytime) * 100
@property (nonatomic) long lastTotalPlaytime; // ms, latest value from event attributes

@property (nonatomic) double downloadRateSum; // running total of all readings
@property (nonatomic) long downloadRateSampleCount; // how many readings we've seen
@property (nonatomic, strong, nullable) NSNumber *minDownloadRate; // smallest reading so far
@property (nonatomic, strong, nullable) NSNumber *maxDownloadRate; // largest reading so far
// Last accepted sample. nil until the first observation.
@property (nonatomic, strong, nullable) NSNumber *lastDownloadRateSample;

// --- Rendition switch counts ---
@property (nonatomic) long totalSwitchUps; // count of quality upgrades (shift == "up")
@property (nonatomic) long totalSwitchDowns; // count of quality downgrades (shift == "down")

// --- Pause accumulator ---
@property (nonatomic) long totalPauseTime; // ms; closed-segment total
@property (nonatomic) NSTimeInterval pauseStartTimestamp; // wall-clock secs; 0 = not paused

// --- Distinct content renditions seen this session ---
@property (nonatomic, strong) NSMutableSet<NSNumber *> *playedRenditions;

@end

@implementation NRQoEAggregator
Expand All @@ -73,10 +92,21 @@ - (void)reset {
self.bitrateTotalDuration = 0;
self.totalRebufferingTime = 0;
self.hasSkippedFirstBuffer = NO;
self.downloadRateSum = 0;
self.downloadRateSampleCount = 0;
self.minDownloadRate = nil;
self.maxDownloadRate = nil;
self.lastDownloadRateSample = nil;
self.totalSwitchUps = 0;
self.totalSwitchDowns = 0;
self.hadStartupError = NO;
self.hadPlaybackError = NO;
self.lastTotalPlaytime = 0;
self.totalPauseTime = 0;
self.pauseStartTimestamp = 0;
self.playedRenditions = [NSMutableSet set];
_totalPreRollAdTime = 0;
_adBreakActive = NO;
}
}

Expand Down Expand Up @@ -104,15 +134,32 @@ + (void)initialize {
CONTENT_END: ^(NRQoEAggregator *agg, NSDictionary *attrs) {
[agg flushBitrateSegment];
},
CONTENT_RENDITION_CHANGE: ^(NRQoEAggregator *agg, NSDictionary *attrs) {
[agg handleRenditionChangeWithAttributes:attrs];
},
CONTENT_PAUSE: ^(NRQoEAggregator *agg, NSDictionary *attrs) {
[agg handlePause];
},
CONTENT_RESUME: ^(NRQoEAggregator *agg, NSDictionary *attrs) {
[agg handleResumeWithAttributes:attrs];
},
};

}
}

// Called from NRVideoTracker's preSendAction: for every CONTENT_* event.
// At this point, the tracker pipeline has already assembled all attributes
// (timeSince values, bitrate, playtime, bufferType, etc.), so we just read them.
- (void)processAction:(NSString *)action attributes:(NSDictionary *)attributes isPlaying:(BOOL)isPlaying {
[self processAction:action attributes:attributes isPlaying:isPlaying adBreakActive:NO];
}

- (void)processAction:(NSString *)action attributes:(NSDictionary *)attributes isPlaying:(BOOL)isPlaying adBreakActive:(BOOL)adBreakActive {
@synchronized (self) {
// Stash ad-break state before the action handler runs (handlePause reads it).
_adBreakActive = adBreakActive;

// Always grab the latest totalPlaytime — the tracker updates this before every event
NSNumber *playtime = attributes[@"totalPlaytime"];
if (playtime) {
Expand All @@ -129,9 +176,15 @@ - (void)processAction:(NSString *)action attributes:(NSDictionary *)attributes i
[self resumeBitrateTimer];
}

[self updateDownloadRateFromAttributes:attributes];

// Track bitrate from every content event for time-weighted average + peak
[self updateBitrateFromAttributes:attributes];

// CONTENT_RENDITION_CHANGE. Recording here lets the first event carrying a valid
// W×H (e.g. CONTENT_BUFFER_END / heartbeat) seed the set. The Set dedups, so repeats don't over-count.
[self recordCurrentRenditionFromAttributes:attributes];

// Action-specific KPI extraction via dispatch table
QoEActionHandler handler = sActionHandlers[action];
if (handler) {
Expand Down Expand Up @@ -191,6 +244,18 @@ - (nullable NSDictionary *)generateAggregateAttributes {
} else {
attrs[KPI_REBUFFERING_RATIO] = @(0.0);
}

// --- Total pause time (ms) ---
// For mid-pause harvests.
long pauseTime = self.totalPauseTime;
if (self.pauseStartTimestamp > 0) {
NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
long openSegment = (long)((now - self.pauseStartTimestamp) * 1000.0);
if (openSegment > 0) {
pauseTime += openSegment;
}
}
attrs[KPI_TOTAL_PAUSE_TIME] = @(pauseTime);
}

// --- Error flags ---
Expand All @@ -205,6 +270,24 @@ - (nullable NSDictionary *)generateAggregateAttributes {
attrs[KPI_HAD_STARTUP_ERROR] = @YES;
}

// network download bitrate.
if (self.downloadRateSampleCount > 0) {
attrs[KPI_AVG_DOWNLOAD_RATE] = @((long)round(self.downloadRateSum / self.downloadRateSampleCount));
}
if (self.minDownloadRate != nil) {
attrs[KPI_MIN_DOWNLOAD_RATE] = self.minDownloadRate;
}
if (self.maxDownloadRate != nil) {
attrs[KPI_MAX_DOWNLOAD_RATE] = self.maxDownloadRate;
}

// --- Rendition switch counts (mirrors Android; always emitted) ---
attrs[KPI_TOTAL_SWITCH_UPS] = @(self.totalSwitchUps);
attrs[KPI_TOTAL_SWITCH_DOWNS] = @(self.totalSwitchDowns);

// --- Distinct rendition count ---
attrs[KPI_TOTAL_RENDITIONS] = @((long)self.playedRenditions.count);

return [attrs copy];
}
}
Expand Down Expand Up @@ -238,6 +321,13 @@ - (void)handleStartWithAttributes:(NSDictionary *)attributes {
if (self.lastBitrateChangeTimestamp == 0) {
self.lastBitrateChangeTimestamp = [[NSDate date] timeIntervalSince1970];
}

// Seed the initial rendition. Required because NRTrackerAVPlayer's
// checkRenditionChange (NRTrackerAVPlayer.m:351-354) silently stamps
// lastWidth/Height on the first valid observation WITHOUT firing
// CONTENT_RENDITION_CHANGE — so the change-handler path never sees the
// initial variant. We seed from CONTENT_START's attributes instead.
[self recordCurrentRenditionFromAttributes:attributes];
}

- (void)handleBufferEndWithAttributes:(NSDictionary *)attributes {
Expand Down Expand Up @@ -268,6 +358,53 @@ - (void)handleError {
}
}

// Count rendition up/down switches from the player-published "shift" attribute.
// Fires only on CONTENT_RENDITION_CHANGE. On iOS, shift ("up"/"down") is computed
// from resolution area in NRTrackerAVPlayer. isEqual: is safe against nil and NSNull
- (void)handleRenditionChangeWithAttributes:(NSDictionary *)attributes {
NSString *shift = attributes[@"shift"];
if ([shift isEqual:@"up"]) {
self.totalSwitchUps += 1;
} else if ([shift isEqual:@"down"]) {
self.totalSwitchDowns += 1;
}
[self recordCurrentRenditionFromAttributes:attributes];
}

// Add the current rendition (W × H) to the distinct-variants set.
- (void)recordCurrentRenditionFromAttributes:(NSDictionary *)attributes {
NSNumber *w = attributes[@"contentRenditionWidth"];
NSNumber *h = attributes[@"contentRenditionHeight"];
if (![w isKindOfClass:[NSNumber class]] || ![h isKindOfClass:[NSNumber class]]) return;
long width = [w longValue], height = [h longValue];
if (width <= 0 || height <= 0) return;
[self.playedRenditions addObject:@(width * height)];
}

// CONTENT_PAUSE: arm the open-segment timer. The closed-segment accumulator
// is NOT touched here — it gets fed by timeSincePaused on the matching RESUME.
- (void)handlePause {
// A content pause during an ad break is the player paused for the ad, not a
// user pause — don't arm the timer, so it isn't counted in totalPauseTime.
if (_adBreakActive) {
return;
}
self.pauseStartTimestamp = [[NSDate date] timeIntervalSince1970];
}

// CONTENT_RESUME: bank the closed segment using timeSincePaused (already
// computed by the timeSince table) and disarm the open-segment timer.
//
// IMPORTANT: pauseStartTimestamp = 0 MUST happen here.
- (void)handleResumeWithAttributes:(NSDictionary *)attributes {
// Only bank the closed segment if the matching pause armed the timer.
NSNumber *timeSincePaused = attributes[@"timeSincePaused"];
if (timeSincePaused && self.pauseStartTimestamp > 0) {
self.totalPauseTime += [timeSincePaused longValue];
}
self.pauseStartTimestamp = 0;
}

// TIME-WEIGHTED AVERAGE BITRATE ALGORITHM:
//
// We track bitrate as a series of segments. Each segment has a bitrate and duration.
Expand Down Expand Up @@ -319,6 +456,33 @@ - (void)updateBitrateFromAttributes:(NSDictionary *)attributes {
self.currentBitrate = bitrate;
}

- (void)updateDownloadRateFromAttributes:(NSDictionary *)attributes {
NSNumber *downloadRateValue = attributes[@"contentNetworkDownloadBitrate"];
if (![downloadRateValue isKindOfClass:[NSNumber class]]) {
return;
}

long sample = [downloadRateValue longValue];
if (sample <= 0) {
return;
}

if (self.lastDownloadRateSample != nil
&& [self.lastDownloadRateSample longValue] == sample) {
return;
}
self.lastDownloadRateSample = @(sample);

self.downloadRateSum += sample;
self.downloadRateSampleCount += 1;

self.minDownloadRate = (self.minDownloadRate == nil)
? @(sample) : @(MIN([self.minDownloadRate longValue], sample));

self.maxDownloadRate = (self.maxDownloadRate == nil)
? @(sample) : @(MAX([self.maxDownloadRate longValue], sample));
}

// Called on CONTENT_END to close the final bitrate segment.
// Without this, the last segment between the last bitrate change and content end
// would be lost from the accumulated weighted sum.
Expand Down
25 changes: 23 additions & 2 deletions NewRelicVideoCore/NewRelicVideoCore/NRVideoDefs.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
#define AD_CLICK @"AD_CLICK"

#define QOE_AGGREGATE @"QOE_AGGREGATE"
#define QOE_AGGREGATE_VERSION @"1.0.0"
#define QOE_AGGREGATE_VERSION @"1.1.0"

// --- Base attribute names (C strings, no prefix) ---
// These define WHAT is being measured. Each is a raw name without any category prefix.
Expand All @@ -62,6 +62,13 @@
#define ATTR_REBUFFERING_RATIO "rebufferingRatio"
#define ATTR_HAD_STARTUP_ERROR "hadStartupError"
#define ATTR_HAD_PLAYBACK_ERROR "hadPlaybackError"
#define ATTR_AVG_DOWNLOAD_RATE "avgDownloadRate"
#define ATTR_MIN_DOWNLOAD_RATE "minDownloadRate"
#define ATTR_MAX_DOWNLOAD_RATE "maxDownloadRate"
#define ATTR_TOTAL_SWITCH_UPS "totalSwitchUps"
#define ATTR_TOTAL_SWITCH_DOWNS "totalSwitchDowns"
#define ATTR_TOTAL_PAUSE_TIME "totalPauseTime"
#define ATTR_TOTAL_RENDITIONS "totalRenditions"

// --- Category prefixes (C strings) ---
// Each category gets its own NRQL namespace prefix.
Expand All @@ -80,6 +87,13 @@
#define KPI_REBUFFERING_RATIO @QOE_PREFIX ATTR_REBUFFERING_RATIO
#define KPI_HAD_STARTUP_ERROR @QOE_PREFIX ATTR_HAD_STARTUP_ERROR
#define KPI_HAD_PLAYBACK_ERROR @QOE_PREFIX ATTR_HAD_PLAYBACK_ERROR
#define KPI_AVG_DOWNLOAD_RATE @QOE_PREFIX ATTR_AVG_DOWNLOAD_RATE
#define KPI_MIN_DOWNLOAD_RATE @QOE_PREFIX ATTR_MIN_DOWNLOAD_RATE
#define KPI_MAX_DOWNLOAD_RATE @QOE_PREFIX ATTR_MAX_DOWNLOAD_RATE
#define KPI_TOTAL_SWITCH_UPS @QOE_PREFIX ATTR_TOTAL_SWITCH_UPS
#define KPI_TOTAL_SWITCH_DOWNS @QOE_PREFIX ATTR_TOTAL_SWITCH_DOWNS
#define KPI_TOTAL_PAUSE_TIME @QOE_PREFIX ATTR_TOTAL_PAUSE_TIME
#define KPI_TOTAL_RENDITIONS @QOE_PREFIX ATTR_TOTAL_RENDITIONS

// --- Centralized list of all QoE KPI attribute keys ---
// When adding a new KPI_* macro above, also add it to this array.
Expand All @@ -96,7 +110,14 @@ static inline NSArray<NSString *> *NRVAAllKPIKeys(void) {
KPI_TOTAL_REBUFFERING_TIME,
KPI_REBUFFERING_RATIO,
KPI_HAD_STARTUP_ERROR,
KPI_HAD_PLAYBACK_ERROR
KPI_HAD_PLAYBACK_ERROR,
KPI_AVG_DOWNLOAD_RATE,
KPI_MIN_DOWNLOAD_RATE,
KPI_MAX_DOWNLOAD_RATE,
KPI_TOTAL_SWITCH_UPS,
KPI_TOTAL_SWITCH_DOWNS,
KPI_TOTAL_PAUSE_TIME,
KPI_TOTAL_RENDITIONS
];
});
return keys;
Expand Down
Loading