UNPKG

clean-insights-sdk

Version:

A privacy-preserving measurement framework.

485 lines (484 loc) 21.2 kB
"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;