fune/browser/components/newtab/lib/DiscoveryStreamFeed.jsm

666 lines
22 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
ChromeUtils.defineModuleGetter(this, "perfService", "resource://activity-stream/common/PerfService.jsm");
const {actionTypes: at, actionCreators: ac} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
const {PersistentCache} = ChromeUtils.import("resource://activity-stream/lib/PersistentCache.jsm");
const CACHE_KEY = "discovery_stream";
const LAYOUT_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
const STARTUP_CACHE_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000; // 1 week
const COMPONENT_FEEDS_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
const SPOCS_FEEDS_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
const MAX_LIFETIME_CAP = 500; // Guard against misconfiguration on the server
const PREF_CONFIG = "discoverystream.config";
const PREF_OPT_OUT = "discoverystream.optOut.0";
const PREF_SHOW_SPONSORED = "showSponsored";
const PREF_SPOC_IMPRESSIONS = "discoverystream.spoc.impressions";
this.DiscoveryStreamFeed = class DiscoveryStreamFeed {
constructor() {
// Internal state for checking if we've intialized all our data
this.loaded = false;
// Persistent cache for remote endpoint data.
this.cache = new PersistentCache(CACHE_KEY, true);
// Internal in-memory cache for parsing json prefs.
this._prefCache = {};
}
finalLayoutEndpoint(url, apiKey) {
if (url.includes("$apiKey") && !apiKey) {
throw new Error(`Layout Endpoint - An API key was specified but none configured: ${url}`);
}
return url.replace("$apiKey", apiKey);
}
get config() {
if (this._prefCache.config) {
return this._prefCache.config;
}
try {
this._prefCache.config = JSON.parse(this.store.getState().Prefs.values[PREF_CONFIG]);
const layoutUrl = this._prefCache.config.layout_endpoint;
const apiKeyPref = this._prefCache.config.api_key_pref;
if (layoutUrl && apiKeyPref) {
const apiKey = Services.prefs.getCharPref(apiKeyPref, "");
this._prefCache.config.layout_endpoint = this.finalLayoutEndpoint(layoutUrl, apiKey);
}
// Modify the cached config with the user set opt-out for other consumers
this._prefCache.config.enabled = this._prefCache.config.enabled &&
!this.store.getState().Prefs.values[PREF_OPT_OUT];
} catch (e) {
// istanbul ignore next
this._prefCache.config = {};
// istanbul ignore next
Cu.reportError(`Could not parse preference. Try resetting ${PREF_CONFIG} in about:config. ${e}`);
}
return this._prefCache.config;
}
get showSpocs() {
// Combine user-set sponsored opt-out with Mozilla-set config
return this.store.getState().Prefs.values[PREF_SHOW_SPONSORED] && this.config.show_spocs;
}
setupPrefs() {
// Send the initial state of the pref on our reducer
this.store.dispatch(ac.BroadcastToContent({type: at.DISCOVERY_STREAM_CONFIG_SETUP, data: this.config}));
}
uninitPrefs() {
// Reset in-memory cache
this._prefCache = {};
}
async fetchFromEndpoint(endpoint) {
if (!endpoint) {
Cu.reportError("Tried to fetch endpoint but none was configured.");
return null;
}
try {
const response = await fetch(endpoint, {credentials: "omit"});
if (!response.ok) {
// istanbul ignore next
throw new Error(`${endpoint} returned unexpected status: ${response.status}`);
}
return response.json();
} catch (error) {
// istanbul ignore next
Cu.reportError(`Failed to fetch ${endpoint}: ${error.message}`);
}
// istanbul ignore next
return null;
}
/**
* Returns true if data in the cache for a particular key has expired or is missing.
* @param {object} cachedData data returned from cache.get()
* @param {string} key a cache key
* @param {string?} url for "feed" only, the URL of the feed.
* @param {boolean} is this check done at initial browser load
*/
isExpired({cachedData, key, url, isStartup}) {
const {layout, spocs, feeds} = cachedData;
const updateTimePerComponent = {
"layout": LAYOUT_UPDATE_TIME,
"spocs": SPOCS_FEEDS_UPDATE_TIME,
"feed": COMPONENT_FEEDS_UPDATE_TIME,
};
const EXPIRATION_TIME = isStartup ? STARTUP_CACHE_EXPIRE_TIME : updateTimePerComponent[key];
switch (key) {
case "layout":
return (!layout || !(Date.now() - layout.lastUpdated < EXPIRATION_TIME));
case "spocs":
return (!spocs || !(Date.now() - spocs.lastUpdated < EXPIRATION_TIME));
case "feed":
return (!feeds || !feeds[url] || !(Date.now() - feeds[url].lastUpdated < EXPIRATION_TIME));
default:
// istanbul ignore next
throw new Error(`${key} is not a valid key`);
}
}
async _checkExpirationPerComponent() {
const cachedData = await this.cache.get() || {};
const {feeds} = cachedData;
return {
layout: this.isExpired({cachedData, key: "layout"}),
spocs: this.isExpired({cachedData, key: "spocs"}),
feeds: !feeds || Object.keys(feeds).some(url => this.isExpired({cachedData, key: "feed", url})),
};
}
/**
* Returns true if any data for the cached endpoints has expired or is missing.
*/
async checkIfAnyCacheExpired() {
const expirationPerComponent = await this._checkExpirationPerComponent();
return expirationPerComponent.layout ||
expirationPerComponent.spocs ||
expirationPerComponent.feeds;
}
async loadLayout(sendUpdate, isStartup) {
const cachedData = await this.cache.get() || {};
let {layout} = cachedData;
if (this.isExpired({cachedData, key: "layout", isStartup})) {
const start = perfService.absNow();
const layoutResponse = await this.fetchFromEndpoint(this.config.layout_endpoint);
if (layoutResponse && layoutResponse.layout) {
this.layoutRequestTime = Math.round(perfService.absNow() - start);
layout = {
lastUpdated: Date.now(),
spocs: layoutResponse.spocs,
layout: layoutResponse.layout,
};
await this.cache.set("layout", layout);
} else {
Cu.reportError("No response for response.layout prop");
}
}
if (layout && layout.layout) {
sendUpdate({
type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
data: layout,
});
}
if (layout && layout.spocs && layout.spocs.url) {
sendUpdate({
type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT,
data: layout.spocs.url,
});
}
}
/**
* buildFeedPromise - Adds the promise result to newFeeds and
* pushes a promise to newsFeedsPromises.
* @param {Object} Has both newFeedsPromises (Array) and newFeeds (Object)
* @param {Boolean} isStartup We have different cache handling for startup.
* @returns {Function} We return a function so we can contain
* the scope for isStartup and the promises object.
* Combines feed results and promises for each component with a feed.
*/
buildFeedPromise({newFeedsPromises, newFeeds}, isStartup) {
return component => {
const {url} = component.feed;
if (!newFeeds[url]) {
// We initially stub this out so we don't fetch dupes,
// we then fill in with the proper object inside the promise.
newFeeds[url] = {};
const feedPromise = this.getComponentFeed(url, isStartup);
feedPromise.then(data => {
newFeeds[url] = data;
}).catch(/* istanbul ignore next */ error => {
Cu.reportError(`Error trying to load component feed ${url}: ${error}`);
});
newFeedsPromises.push(feedPromise);
}
};
}
/**
* reduceFeedComponents - Filters out components with no feeds, and combines
* all feeds on this component with the feeds from other components.
* @param {Boolean} isStartup We have different cache handling for startup.
* @returns {Function} We return a function so we can contain the scope for isStartup.
* Reduces feeds into promises and feed data.
*/
reduceFeedComponents(isStartup) {
return (accumulator, row) => {
row.components
.filter(component => component && component.feed)
.forEach(this.buildFeedPromise(accumulator, isStartup));
return accumulator;
};
}
/**
* buildFeedPromises - Filters out rows with no components,
* and gets us a promise for each unique feed.
* @param {Object} layout This is the Discovery Stream layout object.
* @param {Boolean} isStartup We have different cache handling for startup.
* @returns {Object} An object with newFeedsPromises (Array) and newFeeds (Object),
* we can Promise.all newFeedsPromises to get completed data in newFeeds.
*/
buildFeedPromises(layout, isStartup) {
const initialData = {
newFeedsPromises: [],
newFeeds: {},
};
return layout
.filter(row => row && row.components)
.reduce(this.reduceFeedComponents(isStartup), initialData);
}
async loadComponentFeeds(sendUpdate, isStartup) {
const {DiscoveryStream} = this.store.getState();
if (!DiscoveryStream || !DiscoveryStream.layout) {
return;
}
// Reset the flag that indicates whether or not at least one API request
// was issued to fetch the component feed in `getComponentFeed()`.
this.componentFeedFetched = false;
const start = perfService.absNow();
const {newFeedsPromises, newFeeds} = this.buildFeedPromises(DiscoveryStream.layout, isStartup);
// Each promise has a catch already built in, so no need to catch here.
await Promise.all(newFeedsPromises);
if (this.componentFeedFetched) {
this.componentFeedRequestTime = Math.round(perfService.absNow() - start);
}
await this.cache.set("feeds", newFeeds);
sendUpdate({type: at.DISCOVERY_STREAM_FEEDS_UPDATE, data: newFeeds});
}
async loadSpocs(sendUpdate, isStartup) {
const cachedData = await this.cache.get() || {};
let spocs;
if (this.showSpocs) {
spocs = cachedData.spocs;
if (this.isExpired({cachedData, key: "spocs", isStartup})) {
const endpoint = this.store.getState().DiscoveryStream.spocs.spocs_endpoint;
const start = perfService.absNow();
const spocsResponse = await this.fetchFromEndpoint(endpoint);
if (spocsResponse) {
this.spocsRequestTime = Math.round(perfService.absNow() - start);
spocs = {
lastUpdated: Date.now(),
data: spocsResponse,
};
this.cleanUpCampaignImpressionPref(spocs.data);
await this.cache.set("spocs", spocs);
} else {
Cu.reportError("No response for spocs_endpoint prop");
}
}
}
// Use good data if we have it, otherwise nothing.
// We can have no data if spocs set to off.
// We can have no data if request fails and there is no good cache.
// We want to send an update spocs or not, so client can render something.
spocs = spocs || {
lastUpdated: Date.now(),
data: {},
};
sendUpdate({
type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
data: {
lastUpdated: spocs.lastUpdated,
spocs: this.filterSpocs(spocs.data),
},
});
}
// Filter spocs based on frequency caps
filterSpocs(data) {
if (data && data.spocs && data.spocs.length) {
const {spocs} = data;
const impressions = this.readImpressionsPref(PREF_SPOC_IMPRESSIONS);
return {
...data,
spocs: spocs.filter(s => this.isBelowFrequencyCap(impressions, s)),
};
}
return data;
}
// Frequency caps are based on campaigns, which may include multiple spocs.
// We currently support two types of frequency caps:
// - lifetime: Indicates how many times spocs from a campaign can be shown in total
// - period: Indicates how many times spocs from a campaign can be shown within a period
//
// So, for example, the feed configuration below defines that for campaign 1 no more
// than 5 spocs can be shown in total, and no more than 2 per hour.
// "campaign_id": 1,
// "caps": {
// "lifetime": 5,
// "campaign": {
// "count": 2,
// "period": 3600
// }
// }
isBelowFrequencyCap(impressions, spoc) {
const campaignImpressions = impressions[spoc.campaign_id];
if (!campaignImpressions) {
return true;
}
const lifetime = spoc.caps && spoc.caps.lifetime;
const lifeTimeCap = Math.min(lifetime || MAX_LIFETIME_CAP, MAX_LIFETIME_CAP);
const lifeTimeCapExceeded = campaignImpressions.length >= lifeTimeCap;
if (lifeTimeCapExceeded) {
return false;
}
const campaignCap = spoc.caps && spoc.caps.campaign;
if (campaignCap) {
const campaignCapExceeded = campaignImpressions
.filter(i => (Date.now() - i) < (campaignCap.period * 1000)).length >= campaignCap.count;
return !campaignCapExceeded;
}
return true;
}
async getComponentFeed(feedUrl, isStartup) {
const cachedData = await this.cache.get() || {};
const {feeds} = cachedData;
let feed = feeds ? feeds[feedUrl] : null;
if (this.isExpired({cachedData, key: "feed", url: feedUrl, isStartup})) {
const feedResponse = await this.fetchFromEndpoint(feedUrl);
if (feedResponse) {
this.componentFeedFetched = true;
feed = {
lastUpdated: Date.now(),
data: feedResponse,
};
} else {
Cu.reportError("No response for feed");
}
}
return feed;
}
/**
* Called at startup to update cached data in the background.
*/
async _maybeUpdateCachedData() {
const expirationPerComponent = await this._checkExpirationPerComponent();
// Pass in `store.dispatch` to send the updates only to main
if (expirationPerComponent.layout) {
await this.loadLayout(this.store.dispatch);
}
if (expirationPerComponent.spocs) {
await this.loadSpocs(this.store.dispatch);
}
if (expirationPerComponent.feeds) {
await this.loadComponentFeeds(this.store.dispatch);
}
}
/**
* @typedef {Object} RefreshAllOptions
* @property {boolean} updateOpenTabs - Sends updates to open tabs immediately if true,
* updates in background if false
* @property {boolean} isStartup - When the function is called at browser startup
*
* Refreshes layout, component feeds, and spocs in order if caches have expired.
* @param {RefreshAllOptions} options
*/
async refreshAll(options = {}) {
const {updateOpenTabs, isStartup} = options;
const dispatch = updateOpenTabs ?
action => this.store.dispatch(ac.BroadcastToContent(action)) :
this.store.dispatch;
await this.loadLayout(dispatch, isStartup);
await Promise.all([
this.loadComponentFeeds(dispatch, isStartup).catch(error => Cu.reportError(`Error trying to load component feeds: ${error}`)),
this.loadSpocs(dispatch, isStartup).catch(error => Cu.reportError(`Error trying to load spocs feed: ${error}`)),
]);
if (isStartup) {
await this._maybeUpdateCachedData();
}
this.loaded = true;
}
/**
* Reports the cache age in second for Discovery Stream.
*/
async reportCacheAge() {
const cachedData = await this.cache.get() || {};
const {layout, spocs, feeds} = cachedData;
let cacheAge = Date.now();
let updated = false;
if (layout && layout.lastUpdated && layout.lastUpdated < cacheAge) {
updated = true;
cacheAge = layout.lastUpdated;
}
if (spocs && spocs.lastUpdated && spocs.lastUpdated < cacheAge) {
updated = true;
cacheAge = spocs.lastUpdated;
}
if (feeds) {
Object.keys(feeds).forEach(url => {
const feed = feeds[url];
if (feed.lastUpdated && feed.lastUpdated < cacheAge) {
updated = true;
cacheAge = feed.lastUpdated;
}
});
}
if (updated) {
this.store.dispatch(ac.PerfEvent({
event: "DS_CACHE_AGE_IN_SEC",
value: Math.round((Date.now() - cacheAge) / 1000),
}));
}
}
/**
* Reports various time durations when the feed is requested from endpoint for
* the first time. This could happen on the browser start-up, or the pref changes
* of discovery stream.
*
* Metrics to be reported:
* - Request time for layout endpoint
* - Request time for feed endpoint
* - Request time for spoc endpoint
* - Total request time for data completeness
*/
reportRequestTime() {
if (this.layoutRequestTime) {
this.store.dispatch(ac.PerfEvent({
event: "LAYOUT_REQUEST_TIME",
value: this.layoutRequestTime,
}));
}
if (this.spocsRequestTime) {
this.store.dispatch(ac.PerfEvent({
event: "SPOCS_REQUEST_TIME",
value: this.spocsRequestTime,
}));
}
if (this.componentFeedRequestTime) {
this.store.dispatch(ac.PerfEvent({
event: "COMPONENT_FEED_REQUEST_TIME",
value: this.componentFeedRequestTime,
}));
}
if (this.totalRequestTime) {
this.store.dispatch(ac.PerfEvent({
event: "DS_FEED_TOTAL_REQUEST_TIME",
value: this.totalRequestTime,
}));
}
}
async enable() {
// Note that cache age needs to be reported prior to refreshAll.
await this.reportCacheAge();
const start = perfService.absNow();
await this.refreshAll({updateOpenTabs: true, isStartup: true});
this.totalRequestTime = Math.round(perfService.absNow() - start);
this.reportRequestTime();
}
async disable() {
await this.clearCache();
// Reset reducer
this.store.dispatch(ac.BroadcastToContent({type: at.DISCOVERY_STREAM_LAYOUT_RESET}));
this.loaded = false;
this.layoutRequestTime = undefined;
this.spocsRequestTime = undefined;
this.componentFeedRequestTime = undefined;
this.totalRequestTime = undefined;
}
async clearCache() {
await this.cache.set("layout", {});
await this.cache.set("feeds", {});
await this.cache.set("spocs", {});
}
async onPrefChange() {
if (this.config.enabled) {
// We always want to clear the cache if the pref has changed
await this.clearCache();
// Load data from all endpoints
await this.enable();
}
// Clear state and relevant listeners if config.enabled = false.
if (this.loaded && !this.config.enabled) {
await this.disable();
}
}
recordCampaignImpression(campaignId) {
let impressions = this.readImpressionsPref(PREF_SPOC_IMPRESSIONS);
const timeStamps = impressions[campaignId] || [];
timeStamps.push(Date.now());
impressions = {...impressions, [campaignId]: timeStamps};
this.writeImpressionsPref(PREF_SPOC_IMPRESSIONS, impressions);
}
cleanUpCampaignImpressionPref(data) {
if (data.spocs && data.spocs.length) {
const campaignIds = data.spocs.map(s => `${s.campaign_id}`);
this.cleanUpImpressionPref(id => !campaignIds.includes(id), PREF_SPOC_IMPRESSIONS);
}
}
writeImpressionsPref(pref, impressions) {
this.store.dispatch(ac.SetPref(pref, JSON.stringify(impressions)));
}
readImpressionsPref(pref) {
const prefVal = this.store.getState().Prefs.values[pref];
return prefVal ? JSON.parse(prefVal) : {};
}
cleanUpImpressionPref(isExpired, pref) {
const impressions = this.readImpressionsPref(pref);
let changed = false;
Object
.keys(impressions)
.forEach(id => {
if (isExpired(id)) {
changed = true;
delete impressions[id];
}
});
if (changed) {
this.writeImpressionsPref(pref, impressions);
}
}
async onAction(action) {
switch (action.type) {
case at.INIT:
// During the initialization of Firefox:
// 1. Set-up listeners and initialize the redux state for config;
this.setupPrefs();
// 2. If config.enabled is true, start loading data.
if (this.config.enabled) {
await this.enable();
}
break;
case at.SYSTEM_TICK:
// Only refresh if we loaded once in .enable()
if (this.config.enabled && this.loaded && await this.checkIfAnyCacheExpired()) {
await this.refreshAll({updateOpenTabs: false});
}
break;
case at.DISCOVERY_STREAM_CONFIG_SET_VALUE:
// Disable opt-out if we're explicitly trying to enable
if (action.data.name === "enabled" && action.data.value) {
this.store.dispatch(ac.SetPref(PREF_OPT_OUT, false));
}
// Use the original string pref to then set a value instead of
// this.config which has some modifications
this.store.dispatch(ac.SetPref(PREF_CONFIG, JSON.stringify({
...JSON.parse(this.store.getState().Prefs.values[PREF_CONFIG]),
[action.data.name]: action.data.value,
})));
break;
case at.DISCOVERY_STREAM_CONFIG_CHANGE:
// When the config pref changes, load or unload data as needed.
await this.onPrefChange();
break;
case at.DISCOVERY_STREAM_OPT_OUT:
this.store.dispatch(ac.SetPref(PREF_OPT_OUT, true));
break;
case at.DISCOVERY_STREAM_SPOC_IMPRESSION:
if (this.showSpocs) {
this.recordCampaignImpression(action.data.campaignId);
const cachedData = await this.cache.get() || {};
const {spocs} = cachedData;
this.store.dispatch(ac.AlsoToPreloaded({
type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
data: {
lastUpdated: spocs.lastUpdated,
spocs: this.filterSpocs(spocs.data),
},
}));
}
break;
case at.UNINIT:
// When this feed is shutting down:
this.uninitPrefs();
break;
case at.PREF_CHANGED:
switch (action.data.name) {
case PREF_CONFIG:
case PREF_OPT_OUT:
// Clear the cached config and broadcast the newly computed value
this._prefCache.config = null;
this.store.dispatch(ac.BroadcastToContent({
type: at.DISCOVERY_STREAM_CONFIG_CHANGE,
data: this.config,
}));
break;
// Check if spocs was disabled. Remove them if they were.
case PREF_SHOW_SPONSORED:
await this.loadSpocs(update => this.store.dispatch(ac.BroadcastToContent(update)));
break;
}
break;
}
}
};
const EXPORTED_SYMBOLS = ["DiscoveryStreamFeed"];