commit 4873b3661d6c3ce38eb23078a05fa162a8e4aa57
parent d6031228e77452bd4cc8105ad627a6637e0350ee
Author: Maxx Crawford <mcrawford@mozilla.com>
Date: Fri, 9 Jan 2026 01:05:05 +0000
Bug 1993586 - Add timing distribution metric for spoc placeholder duration r=home-newtab-reviewers,mconley
This patch adds a Glean timing_distribution metric to measure how long
spoc placeholders are visible to users before being replaced with actual
sponsored content when using onDemand mode.
Differential Revision: https://phabricator.services.mozilla.com/D276936
Diffstat:
7 files changed, 389 insertions(+), 0 deletions(-)
diff --git a/browser/components/newtab/metrics.yaml b/browser/components/newtab/metrics.yaml
@@ -2023,6 +2023,28 @@ pocket:
send_in_pings:
- newtab
+ spoc_placeholder_duration:
+ type: timing_distribution
+ time_unit: millisecond
+ description: >
+ Time in milliseconds that a placeholder for a sponsored story (spoc) is
+ visible to the user before being replaced with actual sponsored content.
+ This measures how long users see loading placeholders when spocs need to
+ be fetched, which can happen during startup, cache expiration, or other
+ loading scenarios.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1993586
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1993586
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - mcrawford@mozilla.com
+ expires: never
+ send_in_pings:
+ - newtab
+
+
shim:
type: text
lifetime: ping
diff --git a/browser/extensions/newtab/common/Actions.mjs b/browser/extensions/newtab/common/Actions.mjs
@@ -84,6 +84,7 @@ for (const type of [
"DISCOVERY_STREAM_SPOCS_UPDATE",
"DISCOVERY_STREAM_SPOC_BLOCKED",
"DISCOVERY_STREAM_SPOC_IMPRESSION",
+ "DISCOVERY_STREAM_SPOC_PLACEHOLDER_DURATION",
"DISCOVERY_STREAM_TOPICS_LOADING",
"DISCOVERY_STREAM_USER_EVENT",
"DOWNLOAD_CHANGED",
diff --git a/browser/extensions/newtab/content-src/components/Base/Base.jsx b/browser/extensions/newtab/content-src/components/Base/Base.jsx
@@ -123,6 +123,7 @@ export class BaseContent extends React.PureComponent {
visible: false,
showSectionsMgmtPanel: false,
};
+ this.spocPlaceholderStartTime = null;
}
setFirstVisibleTimestamp() {
@@ -140,6 +141,10 @@ export class BaseContent extends React.PureComponent {
this.setFirstVisibleTimestamp();
this.shouldDisplayTopicSelectionModal();
this.onVisibilityDispatch();
+
+ if (this.isSpocsOnDemandExpired && !this.spocPlaceholderStartTime) {
+ this.spocPlaceholderStartTime = Date.now();
+ }
}
onVisibilityDispatch() {
@@ -333,6 +338,36 @@ export class BaseContent extends React.PureComponent {
}
this.spocsOnDemandUpdated();
+ this.trackSpocPlaceholderDuration(prevProps);
+ }
+
+ trackSpocPlaceholderDuration(prevProps) {
+ // isExpired returns true when the current props have expired spocs (showing placeholders)
+ const isExpired = this.isSpocsOnDemandExpired;
+
+ // Init tracking when placeholders become visible
+ if (isExpired && this.state.visible && !this.spocPlaceholderStartTime) {
+ this.spocPlaceholderStartTime = Date.now();
+ }
+
+ // wasExpired returns true when the previous props had expired spocs (showing placeholders)
+ const wasExpired =
+ prevProps.DiscoveryStream.spocs.onDemand?.enabled &&
+ !prevProps.DiscoveryStream.spocs.onDemand?.loaded &&
+ Date.now() - prevProps.DiscoveryStream.spocs.lastUpdated >=
+ prevProps.DiscoveryStream.spocs.cacheUpdateTime;
+
+ // Record duration telemetry event when placeholders are replaced with real content
+ if (wasExpired && !isExpired && this.spocPlaceholderStartTime) {
+ const duration = Date.now() - this.spocPlaceholderStartTime;
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_SPOC_PLACEHOLDER_DURATION,
+ data: { duration },
+ })
+ );
+ this.spocPlaceholderStartTime = null;
+ }
}
handleColorModeChange() {
diff --git a/browser/extensions/newtab/data/content/activity-stream.bundle.js b/browser/extensions/newtab/data/content/activity-stream.bundle.js
@@ -157,6 +157,7 @@ for (const type of [
"DISCOVERY_STREAM_SPOCS_UPDATE",
"DISCOVERY_STREAM_SPOC_BLOCKED",
"DISCOVERY_STREAM_SPOC_IMPRESSION",
+ "DISCOVERY_STREAM_SPOC_PLACEHOLDER_DURATION",
"DISCOVERY_STREAM_TOPICS_LOADING",
"DISCOVERY_STREAM_USER_EVENT",
"DOWNLOAD_CHANGED",
@@ -15888,6 +15889,7 @@ class BaseContent extends (external_React_default()).PureComponent {
visible: false,
showSectionsMgmtPanel: false
};
+ this.spocPlaceholderStartTime = null;
}
setFirstVisibleTimestamp() {
if (!this.state.firstVisibleTimestamp) {
@@ -15903,6 +15905,9 @@ class BaseContent extends (external_React_default()).PureComponent {
this.setFirstVisibleTimestamp();
this.shouldDisplayTopicSelectionModal();
this.onVisibilityDispatch();
+ if (this.isSpocsOnDemandExpired && !this.spocPlaceholderStartTime) {
+ this.spocPlaceholderStartTime = Date.now();
+ }
}
onVisibilityDispatch() {
const {
@@ -16064,6 +16069,31 @@ class BaseContent extends (external_React_default()).PureComponent {
}
}
this.spocsOnDemandUpdated();
+ this.trackSpocPlaceholderDuration(prevProps);
+ }
+ trackSpocPlaceholderDuration(prevProps) {
+ // isExpired returns true when the current props have expired spocs (showing placeholders)
+ const isExpired = this.isSpocsOnDemandExpired;
+
+ // Init tracking when placeholders become visible
+ if (isExpired && this.state.visible && !this.spocPlaceholderStartTime) {
+ this.spocPlaceholderStartTime = Date.now();
+ }
+
+ // wasExpired returns true when the previous props had expired spocs (showing placeholders)
+ const wasExpired = prevProps.DiscoveryStream.spocs.onDemand?.enabled && !prevProps.DiscoveryStream.spocs.onDemand?.loaded && Date.now() - prevProps.DiscoveryStream.spocs.lastUpdated >= prevProps.DiscoveryStream.spocs.cacheUpdateTime;
+
+ // Record duration telemetry event when placeholders are replaced with real content
+ if (wasExpired && !isExpired && this.spocPlaceholderStartTime) {
+ const duration = Date.now() - this.spocPlaceholderStartTime;
+ this.props.dispatch(actionCreators.OnlyToMain({
+ type: actionTypes.DISCOVERY_STREAM_SPOC_PLACEHOLDER_DURATION,
+ data: {
+ duration
+ }
+ }));
+ this.spocPlaceholderStartTime = null;
+ }
}
handleColorModeChange() {
const colorMode = this.prefersDarkQuery?.matches ? "dark" : "light";
diff --git a/browser/extensions/newtab/lib/TelemetryFeed.sys.mjs b/browser/extensions/newtab/lib/TelemetryFeed.sys.mjs
@@ -744,6 +744,20 @@ export class TelemetryFeed {
}
}
+ /**
+ * Records the duration that spoc (ads) placeholders were visible to the user.
+ * This tracks how long placeholder content is shown before being replaced
+ * with actual sponsored content when using onDemand mode.
+ *
+ * @param {number} action.data.duration - Duration in milliseconds
+ */
+ handleSpocPlaceholderDuration(action) {
+ const { duration } = action.data;
+ if (duration !== undefined && duration >= 0) {
+ Glean.pocket.spocPlaceholderDuration.accumulateSingleSample(duration);
+ }
+ }
+
handleUserEvent(action) {
let userEvent = this.createUserEvent(action);
try {
@@ -1301,6 +1315,9 @@ export class TelemetryFeed {
action.data
);
break;
+ case at.DISCOVERY_STREAM_SPOC_PLACEHOLDER_DURATION:
+ this.handleSpocPlaceholderDuration(action);
+ break;
case at.DISCOVERY_STREAM_USER_EVENT:
this.handleDiscoveryStreamUserEvent(action);
break;
diff --git a/browser/extensions/newtab/test/unit/content-src/components/Base.test.jsx b/browser/extensions/newtab/test/unit/content-src/components/Base.test.jsx
@@ -220,4 +220,215 @@ describe("WithDsAdmin", () => {
assert.lengthOf(wrapper.find(BaseContent), 0);
});
});
+
+ describe("SPOC Placeholder Duration Tracking", () => {
+ let wrapper;
+ let instance;
+ let dispatch;
+ let clock;
+ let baseProps;
+
+ beforeEach(() => {
+ // Setup: Create a component with expired spocs (showing placeholders)
+ // - useFakeTimers allows us to control time for duration testing
+ // - lastUpdated is 120000ms (2 mins) ago, exceeding cacheUpdateTime of 60000ms (1 min)
+ // - In this setup, spocs are expired and placeholders should be visible
+ clock = sinon.useFakeTimers();
+ dispatch = sinon.spy();
+ baseProps = {
+ store: { getState: () => {} },
+ App: { initialized: true },
+ Prefs: { values: {} },
+ Sections: [],
+ Weather: {},
+ document: {
+ visibilityState: "visible",
+ addEventListener: sinon.stub(),
+ removeEventListener: sinon.stub(),
+ },
+ };
+ const props = {
+ ...baseProps,
+ dispatch,
+ DiscoveryStream: {
+ config: { enabled: true },
+ spocs: {
+ onDemand: { enabled: true, loaded: false },
+ lastUpdated: Date.now() - 120000, // Expired (120s ago)
+ cacheUpdateTime: 60000, // Cache expires after 60s
+ },
+ },
+ };
+ wrapper = shallow(<BaseContent {...props} />);
+ instance = wrapper.instance();
+ instance.setState({ visible: true });
+ });
+
+ afterEach(() => {
+ clock.restore();
+ });
+
+ it("should start tracking when placeholders become visible", () => {
+ const prevProps = {
+ ...baseProps,
+ DiscoveryStream: {
+ config: { enabled: true },
+ spocs: {
+ onDemand: { enabled: true, loaded: false },
+ lastUpdated: Date.now() - 30000,
+ cacheUpdateTime: 60000,
+ },
+ },
+ };
+
+ clock.tick(1000);
+ instance.trackSpocPlaceholderDuration(prevProps);
+
+ assert.isNotNull(instance.spocPlaceholderStartTime);
+ });
+
+ it("should record duration when placeholders are replaced", () => {
+ // Create a fresh wrapper with expired spocs
+ const freshDispatch = sinon.spy();
+ const expiredTime = Date.now() - 120000;
+ const freshWrapper = shallow(
+ <BaseContent
+ {...baseProps}
+ dispatch={freshDispatch}
+ DiscoveryStream={{
+ config: { enabled: true },
+ spocs: {
+ onDemand: { enabled: true, loaded: false },
+ lastUpdated: expiredTime,
+ cacheUpdateTime: 60000,
+ },
+ }}
+ />
+ );
+ const freshInstance = freshWrapper.instance();
+ freshInstance.setState({ visible: true });
+
+ // Advance clock a bit first so startTime is not 0 (which is falsy)
+ clock.tick(100);
+
+ // Set start time and advance clock
+ const startTime = Date.now();
+ freshInstance.spocPlaceholderStartTime = startTime;
+ clock.tick(150);
+
+ // Update to fresh spocs - this triggers componentDidUpdate
+ // which automatically calls trackSpocPlaceholderDuration
+ freshWrapper.setProps({
+ ...baseProps,
+ dispatch: freshDispatch,
+ DiscoveryStream: {
+ config: { enabled: true },
+ spocs: {
+ onDemand: { enabled: true, loaded: false },
+ lastUpdated: Date.now(),
+ cacheUpdateTime: 60000,
+ },
+ },
+ });
+
+ // componentDidUpdate should have dispatched the placeholder duration action
+ const placeholderCall = freshDispatch
+ .getCalls()
+ .find(
+ call =>
+ call.args[0].type === "DISCOVERY_STREAM_SPOC_PLACEHOLDER_DURATION"
+ );
+
+ assert.isNotNull(
+ placeholderCall,
+ "Placeholder duration action should be dispatched"
+ );
+ const [action] = placeholderCall.args;
+ assert.equal(action.data.duration, 150);
+ assert.deepEqual(action.meta, {
+ from: "ActivityStream:Content",
+ to: "ActivityStream:Main",
+ skipLocal: true,
+ });
+
+ assert.isNull(freshInstance.spocPlaceholderStartTime);
+ });
+
+ it("should start tracking on onVisible if placeholders already expired", () => {
+ wrapper.setProps({
+ DiscoveryStream: {
+ config: { enabled: true },
+ spocs: {
+ onDemand: { enabled: true, loaded: false },
+ lastUpdated: Date.now() - 120000,
+ cacheUpdateTime: 60000,
+ },
+ },
+ });
+
+ instance.setState({ visible: false });
+ instance.spocPlaceholderStartTime = null;
+
+ instance.onVisible();
+
+ assert.isNotNull(instance.spocPlaceholderStartTime);
+ });
+
+ it("should not start tracking if tab is not visible", () => {
+ instance.setState({ visible: false });
+ instance.spocPlaceholderStartTime = null;
+
+ const prevProps = {
+ ...baseProps,
+ DiscoveryStream: {
+ config: { enabled: true },
+ spocs: {
+ onDemand: { enabled: true, loaded: false },
+ lastUpdated: Date.now() - 30000,
+ cacheUpdateTime: 60000,
+ },
+ },
+ };
+
+ instance.trackSpocPlaceholderDuration(prevProps);
+
+ assert.isNull(instance.spocPlaceholderStartTime);
+ });
+
+ it("should not start tracking if onDemand is disabled", () => {
+ // Reset instance to have onDemand disabled from the start
+ const props = {
+ ...baseProps,
+ dispatch,
+ DiscoveryStream: {
+ config: { enabled: true },
+ spocs: {
+ onDemand: { enabled: false, loaded: false },
+ lastUpdated: Date.now() - 120000,
+ cacheUpdateTime: 60000,
+ },
+ },
+ };
+ wrapper = shallow(<BaseContent {...props} />);
+ instance = wrapper.instance();
+ instance.setState({ visible: true });
+ instance.spocPlaceholderStartTime = null;
+
+ const prevProps = {
+ ...baseProps,
+ DiscoveryStream: {
+ config: { enabled: true },
+ spocs: {
+ onDemand: { enabled: false, loaded: false },
+ lastUpdated: Date.now() - 120000,
+ cacheUpdateTime: 60000,
+ },
+ },
+ };
+
+ instance.trackSpocPlaceholderDuration(prevProps);
+
+ assert.isNull(instance.spocPlaceholderStartTime);
+ });
+ });
});
diff --git a/browser/extensions/newtab/test/xpcshell/test_TelemetryFeed.js b/browser/extensions/newtab/test/xpcshell/test_TelemetryFeed.js
@@ -2668,3 +2668,76 @@ add_task(function test_randomizeOrganicContentEvent() {
sandbox.restore();
});
+
+add_task(async function test_handleSpocPlaceholderDuration_records_metric() {
+ info(
+ "TelemetryFeed.handleSpocPlaceholderDuration should record the " +
+ "spoc_placeholder_duration metric"
+ );
+
+ let instance = new TelemetryFeed();
+ Services.fog.testResetFOG();
+
+ const DURATION_MS = 150;
+ let action = {
+ type: actionTypes.DISCOVERY_STREAM_SPOC_PLACEHOLDER_DURATION,
+ data: { duration: DURATION_MS },
+ };
+
+ instance.handleSpocPlaceholderDuration(action);
+
+ let recordedDuration = Glean.pocket.spocPlaceholderDuration.testGetValue();
+ Assert.ok(recordedDuration, "Metric should be recorded");
+ Assert.equal(recordedDuration.count, 1, "Should have 1 sample");
+ Assert.greaterOrEqual(
+ recordedDuration.sum,
+ DURATION_MS,
+ "Duration should be at least the input value"
+ );
+});
+
+add_task(async function test_handleSpocPlaceholderDuration_ignores_negative() {
+ info(
+ "TelemetryFeed.handleSpocPlaceholderDuration should ignore negative durations"
+ );
+
+ let instance = new TelemetryFeed();
+ Services.fog.testResetFOG();
+
+ let action = {
+ type: actionTypes.DISCOVERY_STREAM_SPOC_PLACEHOLDER_DURATION,
+ data: { duration: -1 },
+ };
+
+ instance.handleSpocPlaceholderDuration(action);
+
+ let recordedDuration = Glean.pocket.spocPlaceholderDuration.testGetValue();
+ Assert.equal(
+ recordedDuration,
+ null,
+ "Metric should not be recorded for negative duration"
+ );
+});
+
+add_task(async function test_handleSpocPlaceholderDuration_ignores_undefined() {
+ info(
+ "TelemetryFeed.handleSpocPlaceholderDuration should ignore undefined durations"
+ );
+
+ let instance = new TelemetryFeed();
+ Services.fog.testResetFOG();
+
+ let action = {
+ type: actionTypes.DISCOVERY_STREAM_SPOC_PLACEHOLDER_DURATION,
+ data: {},
+ };
+
+ instance.handleSpocPlaceholderDuration(action);
+
+ let recordedDuration = Glean.pocket.spocPlaceholderDuration.testGetValue();
+ Assert.equal(
+ recordedDuration,
+ null,
+ "Metric should not be recorded for undefined duration"
+ );
+});