tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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:
Mbrowser/components/newtab/metrics.yaml | 22++++++++++++++++++++++
Mbrowser/extensions/newtab/common/Actions.mjs | 1+
Mbrowser/extensions/newtab/content-src/components/Base/Base.jsx | 35+++++++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/data/content/activity-stream.bundle.js | 30++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/lib/TelemetryFeed.sys.mjs | 17+++++++++++++++++
Mbrowser/extensions/newtab/test/unit/content-src/components/Base.test.jsx | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/test/xpcshell/test_TelemetryFeed.js | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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" + ); +});