clean-insights-sdk
Version:
A privacy-preserving measurement framework.
485 lines (484 loc) • 21.2 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CleanInsights = void 0;
var BrowserStore_1 = require("./BrowserStore");
var consents_1 = require("./consents");
var Configuration_1 = require("./Configuration");
var DataPoint_1 = require("./DataPoint");
var Event_1 = require("./Event");
var Insights_1 = require("./Insights");
var Visit_1 = require("./Visit");
var dayjs_1 = __importDefault(require("dayjs"));
var duration_1 = __importDefault(require("dayjs/plugin/duration"));
var isSameOrBefore_1 = __importDefault(require("dayjs/plugin/isSameOrBefore"));
var isSameOrAfter_1 = __importDefault(require("dayjs/plugin/isSameOrAfter"));
var localizedFormat_1 = __importDefault(require("dayjs/plugin/localizedFormat"));
dayjs_1.default.extend(duration_1.default);
dayjs_1.default.extend(isSameOrBefore_1.default);
dayjs_1.default.extend(isSameOrAfter_1.default);
dayjs_1.default.extend(localizedFormat_1.default);
var CleanInsights = /** @class */ (function () {
/**
* @param {Object|Configuration} configuration
* The Configuration provided as a `Configuration` object or as a plain dictionary.
*
* @param {Store=} store=BrowserStore
* Either your own implementation of a `Store`. OPTIONAL.
* Defaults to `BrowserStore` which uses Store.js - an abstraction over LocalStorage.
*/
function CleanInsights(configuration, store) {
var _this = this;
this.persistenceCounter = 0;
this.sending = false;
this.failedSubmissionCount = 0;
this.lastFailedSubmission = dayjs_1.default.unix(0);
if (typeof configuration === 'object') {
if (configuration instanceof Configuration_1.Configuration) {
this.conf = configuration;
}
else {
this.conf = new Configuration_1.Configuration(configuration);
}
}
else {
throw new TypeError('Invalid configuration provided.');
}
var debug = function (message) { _this.debug(message); };
if (!this.conf.check(debug)) {
throw new TypeError('Invalid configuration provided.');
}
if (typeof store === 'object' && store) {
this.store = store;
}
else {
this.store = new BrowserStore_1.BrowserStore({}, debug);
}
}
// noinspection JSUnusedGlobalSymbols
/**
* Track a scene visit.
*
* @param {string[]} scenePath
* A hierarchical path best describing the structure of your scenes. E.g. `['Main', 'Settings', 'Some Setting']`.
* @param {string} campaignId
* The campaign ID as per your configuration, where this measurement belongs to.
*/
CleanInsights.prototype.measureVisit = function (scenePath, campaignId) {
var campaign = this.getCampaignIfGood(campaignId, scenePath.join("/"));
if (campaign) {
var visit = this.getAndMeasure(this.store.visits, campaignId, campaign, function (visit) {
return visit.scenePath.join('/') === scenePath.join('/');
});
if (visit) {
this.debug("Gain visit insight: ".concat(visit));
}
else {
// Align first and last timestamps with campaign measurement period,
// in order not to accidentally leak more information than promised.
var period = campaign.currentMeasurementPeriod;
if (period) {
visit = new Visit_1.Visit(scenePath, campaignId, undefined, period.start, period.end);
this.store.visits.push(visit);
this.debug("Gain visit insight: ".concat(visit));
}
else {
this.debug("campaign.currentMeasurementPeriod == null! This should not happen!");
}
}
}
this.persistAndSend();
};
/**
* Track an event.
*
* @param {string} category
* The event category. Must not be empty. (e.g. Videos, Music, Games...)
* @param {string} action
* The event action. Must not be empty. (e.g. Play, Pause, Duration, Add Playlist, Downloaded, Clicked...)
* @param {string} campaignId
* The campaign ID as per your configuration, where this measurement belongs to.
* @param {string=} name
* The event name. OPTIONAL.
* @param {number=} value
* The event value. OPTIONAL.
*/
CleanInsights.prototype.measureEvent = function (category, action, campaignId, name, value) {
var campaign = this.getCampaignIfGood(campaignId, "".concat(category, "/").concat(action));
if (campaign) {
var event_1 = this.getAndMeasure(this.store.events, campaignId, campaign, function (event) {
return event.category === category
&& event.action === action
&& event.name === name;
});
if (event_1) {
campaign.apply(event_1, value);
this.debug("Gain event insight: ".concat(event_1));
}
else {
// Align first and last timestamps with campaign measurement period,
// in order not to accidentally leak more information than promised.
var period = campaign.currentMeasurementPeriod;
if (period) {
event_1 = new Event_1.Event(category, action, name, value, campaignId, undefined, period.start, period.end);
this.store.events.push(event_1);
this.debug("Gain event insight: ".concat(event_1));
}
else {
this.debug("campaign.currentMeasurementPeriod == null! This should not happen!");
}
}
}
this.persistAndSend();
};
Object.defineProperty(CleanInsights.prototype, "featureConsents", {
get: function () {
return Object.keys(this.store.consents.features);
},
enumerable: false,
configurable: true
});
Object.defineProperty(CleanInsights.prototype, "campaignConsents", {
get: function () {
return Object.keys(this.store.consents.campaigns);
},
enumerable: false,
configurable: true
});
CleanInsights.prototype.getFeatureConsentByIndex = function (index) {
var feature = Object.keys(this.store.consents.features)[index];
return this.store.consents.consentForFeature(feature);
};
CleanInsights.prototype.getCampaignConsentByIndex = function (index) {
var campaignId = Object.keys(this.store.consents.campaigns)[index];
return this.store.consents.consentForCampaign(campaignId);
};
CleanInsights.prototype.grantFeature = function (feature) {
var consent = this.store.consents.grantFeature(feature);
this.persistAndSend();
return consent;
};
CleanInsights.prototype.denyFeature = function (feature) {
var consent = this.store.consents.denyFeature(feature);
this.persistAndSend();
return consent;
};
/**
* Returns the consent for a given feature, if any available.
*
* @param {Feature} feature The feature to get the consent for.
* @returns {?FeatureConsent} the `FeatureConsent` for the given feature or `null`, if consent unknown.
*/
CleanInsights.prototype.consentForFeature = function (feature) {
if (this.conf.serverSideAnonymousUsage)
return new consents_1.FeatureConsent(feature, new consents_1.Consent(false));
return this.store.consents.consentForFeature(feature);
};
/**
* Checks the consent state of a feature.
*
* @param {Feature} feature The feature to check the consent state of.
* @returns {ConsentState} the current state of consent.
*/
CleanInsights.prototype.stateOfFeature = function (feature) {
if (this.conf.serverSideAnonymousUsage)
return consents_1.ConsentState.denied;
return this.store.consents.stateOfFeature(feature);
};
CleanInsights.prototype.grantCampaign = function (campaignId) {
if (!this.conf.campaigns.hasOwnProperty(campaignId)) {
return null;
}
var campaign = this.conf.campaigns[campaignId];
var consent = this.store.consents.grantCampaign(campaignId, campaign);
this.persistAndSend();
return consent;
};
CleanInsights.prototype.denyCampaign = function (campaignId) {
if (!this.conf.campaigns.hasOwnProperty(campaignId)) {
return null;
}
var consent = this.store.consents.denyCampaign(campaignId);
this.persistAndSend();
return consent;
};
CleanInsights.prototype.isCampaignCurrentlyGranted = function (campaignId) {
return this.stateOfCampaign(campaignId) == consents_1.ConsentState.granted;
};
/**
* Returns the consent for a given campaign, if any available.
*
* @param {string} campaignId The campaign ID to get the consent for.
* @return {?CampaignConsent} the `CampaignConsent` for the given campaign or `null`, if consent unknown.
*/
CleanInsights.prototype.consentForCampaign = function (campaignId) {
if (this.conf.serverSideAnonymousUsage)
return new consents_1.CampaignConsent(campaignId, new consents_1.Consent(true));
if (!this.conf.campaigns.hasOwnProperty(campaignId))
return null;
if ((0, dayjs_1.default)().isSameOrAfter(this.conf.campaigns[campaignId].end))
return null;
return this.store.consents.consentForCampaign(campaignId);
};
/**
* Checks the consent state of a campaign.
*
* @param {string} campaignId The campaign ID to check the consent state of.
* @return {ConsentState} the current state of consent.
*/
CleanInsights.prototype.stateOfCampaign = function (campaignId) {
if (this.conf.serverSideAnonymousUsage)
return consents_1.ConsentState.granted;
if (!this.conf.campaigns.hasOwnProperty(campaignId))
return consents_1.ConsentState.unconfigured;
if ((0, dayjs_1.default)().isSameOrAfter(this.conf.campaigns[campaignId].end))
return consents_1.ConsentState.unconfigured;
return this.store.consents.stateOfCampaign(campaignId);
};
CleanInsights.prototype.requestConsentForCampaign = function (campaignId, consentRequestUi, completed) {
var _this = this;
if (typeof completed !== 'function') {
completed = function () { };
}
if (!this.conf.campaigns.hasOwnProperty(campaignId)) {
this.debug("Cannot request consent: Campaign '".concat(campaignId, "' not configured."));
completed(false);
return '';
}
var campaign = this.conf.campaigns[campaignId];
if ((0, dayjs_1.default)().isSameOrAfter(campaign.end)) {
this.debug("Cannot request consent: End of campaign '".concat(campaignId, "' reached."));
completed(false);
return '';
}
if (campaign.nextTotalMeasurementPeriod === null) {
this.debug("Cannot request consent: Campaign '".concat(campaignId, "' configuration seems messed up."));
completed(false);
return '';
}
if (this.store.consents.campaigns.hasOwnProperty(campaignId)) {
var consent = this.store.consents.campaigns[campaignId];
this.debug("Already asked for consent for campaign '".concat(campaignId, "'. It was ").concat(consent.granted ? "granted between ".concat(consent.start.format(), " and ").concat(consent.end.format()) : "denied on ".concat(consent.start.format()), "."));
completed(consent.granted);
return '';
}
var complete = function (granted) {
if (granted) {
_this.store.consents.grantCampaign(campaignId, campaign);
}
else {
_this.store.consents.denyCampaign(campaignId);
}
if (completed) {
completed(granted);
}
};
return consentRequestUi.showForCampaign(campaignId, campaign, complete);
};
CleanInsights.prototype.requestConsentForFeature = function (feature, consentRequestUi, completed) {
var _this = this;
if (typeof completed !== 'function') {
completed = function () { };
}
if (this.store.consents.features.hasOwnProperty(feature)) {
var consent = this.store.consents.features[feature];
this.debug("Already asked for consent for feature '".concat(feature, "'. It was ").concat(consent.granted ? "granted" : "denied", " on ").concat(consent.start.format(), "."));
completed(consent.granted);
return '';
}
var complete = function (granted) {
if (granted) {
_this.store.consents.grantFeature(feature);
}
else {
_this.store.consents.denyFeature(feature);
}
if (completed) {
completed(_this.store.consents.stateOfFeature(feature) == consents_1.ConsentState.granted);
}
};
return consentRequestUi.showForFeature(feature, complete);
};
/**
* Sends an empty body to the server for easy debugging of server-related issues like TLS and CORS problems.
*
* **DON'T LEAVE THIS IN PRODUCTION**, once you're done fixing any server issues. There's absolutely no point in
* pinging the server with this all the time, it will undermine your privacy promise to your users!
*
* @param {function(?Error)} done
* Callback, when the operation is finished, either successfully or not.
*/
CleanInsights.prototype.testServer = function (done) {
var _this = this;
this.store.send('', this.conf.server, this.conf.timeout, function (error) {
if (!error) {
error = new Error('Server replied with no error while it should have responded with HTTP 400 Bad Request!');
}
if (error.message.startsWith('HTTP Error 400:')) {
_this.debug('Successfully tested server.');
if (done)
done();
return;
}
_this.debug(error);
if (done)
done(error);
});
};
/**
* Persist accumulated data to the filesystem/local storage.
*
* A website should call this in an `onUnload` event, a Node.JS app should call this when the
* process exits for whatever reason. (See [node-cleanup](https://github.com/jtlapp/node-cleanup)).
*/
CleanInsights.prototype.persist = function () {
this._persist(false, true);
};
/**
* Persist accumulated data to the filesystem/local storage.
*
* @param {boolean} async
* If true, returns immediately and does persistence asynchronously, only if it's already due.
*
* @param {boolean=} force=false
* Write regardless of threshold reached.
*/
CleanInsights.prototype._persist = function (async, force) {
var _this = this;
this.persistenceCounter += 1;
var callback = function (error) {
if (error) {
_this.debug(error);
}
else {
_this.persistenceCounter = 0;
_this.debug("Data persisted to storage.");
}
};
if (force || this.persistenceCounter >= this.conf.persistEveryNTimes) {
this.store.persist(async, callback);
}
};
CleanInsights.prototype.persistAndSend = function () {
var _this = this;
this._persist(true);
if (this.sending) {
this.debug("Data sending already in progress.");
return;
}
this.sending = true;
if (this.failedSubmissionCount > 0) {
// Calculate a delay for the next retry:
// Minimum is 2 times the configured network timeout after the first failure,
// exponentially increasing with number of retries.
// Maximum is every conf.maxRetryDelay interval.
var exp = this.lastFailedSubmission.add(this.conf.timeout * Math.pow(2, this.failedSubmissionCount), 'seconds');
var tru = this.lastFailedSubmission.add(this.conf.maxRetryDelay, 'seconds');
if ((0, dayjs_1.default)().isBefore(exp.isBefore(tru) ? exp : tru)) {
this.sending = false;
this.debug("Waiting longer to send data after ".concat(this.failedSubmissionCount, " failed attempts."));
return;
}
}
var insights = new Insights_1.Insights(this.conf, this.store);
if (insights.isEmpty) {
this.sending = false;
this.debug("No data to send.");
return;
}
var done = function (error) {
if (error) {
_this.lastFailedSubmission = (0, dayjs_1.default)();
_this.failedSubmissionCount++;
_this.debug(error);
}
else {
_this.lastFailedSubmission = dayjs_1.default.unix(0);
_this.failedSubmissionCount = 0;
insights.clean(_this.store);
_this.debug('Successfully sent data.');
_this._persist(true, true);
}
_this.sending = false;
};
var data = JSON.stringify(insights);
this.store.send(data, this.conf.server, this.conf.timeout, done);
};
CleanInsights.prototype.getCampaignIfGood = function (campaignId, debugString) {
if (!this.conf.campaigns.hasOwnProperty(campaignId)) {
return null;
}
var campaign = this.conf.campaigns[campaignId];
var now = (0, dayjs_1.default)();
if (now.isBefore(campaign.start)) {
this.debug("Measurement '".concat(debugString, "' discarded, because campaign '").concat(campaignId, "' didn't start, yet."));
return null;
}
if (now.isAfter(campaign.end)) {
this.debug("Measurement '".concat(debugString, "' discarded, because campaign '").concat(campaignId, "' already ended."));
return null;
}
if (!this.isCampaignCurrentlyGranted(campaignId)) {
this.debug("Measurement '".concat(debugString, "' discarded, because campaign '").concat(campaignId, "' has no user consent yet, any more or we're outside the measurement period."));
return null;
}
return campaign;
};
/**
* Get a `DataPoint` subclass out of the `haystack`, as long as it fits the `campaign`.
* Increases `times` according to the campaigns rules.
*
* Create a new `DataPoint` if nothing is returned here.
*
* @param {DataPoint[]|Visit[]|Event[]} haystack
* The haystack full of `DataPoint` subclasses.
*
* @param {string} campaignId
* The campaign ID it must match.
*
* @param {Campaign} campaign
* The campaign parameters to match against.
*
* @param {function(DataPoint):boolean} where
* Additional condition for selection.
*
* @returns: {?(DataPoint|Visit|Event)} a `DataPoint` subclass out of the `haystack`, as long as it fits the `campaign`.
*/
CleanInsights.prototype.getAndMeasure = function (haystack, campaignId, campaign, where) {
var period = campaign.currentMeasurementPeriod;
if (!period) {
this.debug("campaign.currentMeasurementPeriod == null! This should not happen!");
return null;
}
var dataPoint = haystack.find(function (value) {
return value.campaignId === campaignId
&& value.first.isSameOrAfter(period.start)
&& value.first.isSameOrBefore(period.end)
&& value.last.isSameOrAfter(period.start)
&& value.last.isSameOrBefore(period.end)
&& where(value);
});
if (dataPoint instanceof DataPoint_1.DataPoint) {
if (!campaign.onlyRecordOnce) {
dataPoint.times += 1;
}
return dataPoint;
}
return null;
};
CleanInsights.prototype.debug = function (message) {
if (!this.conf.debug) {
return;
}
if (typeof message === 'object' && message) {
console.debug("[CleanInsightsSDK] ".concat(message.name, ": ").concat(message.message, "\n\n").concat(message.stack));
}
else {
console.debug("[CleanInsightsSDK] ".concat(message));
}
};
return CleanInsights;
}());
exports.CleanInsights = CleanInsights;