___TERMS_OF_SERVICE___ By creating or modifying this file you agree to Google Tag Manager's Community Template Gallery Developer Terms of Service available at https://developers.google.com/tag-manager/gallery-tos (or such other URL as Google may provide), as modified from time to time. ___INFO___ { "type": "TAG", "id": "cvt_temp_public_id", "version": 1, "securityGroups": [], "displayName": "Iterable", "brand": { "id": "github.com_snowplow", "displayName": "snowplow", "thumbnail": "data:image/png;base64,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\u003d\u003d" }, "description": "Tag that sends events to Iterable from Snowplow, GAv4 or other clients to Iterable. Works best with the Snowplow Client.", "containerContexts": [ "SERVER" ] } ___TEMPLATE_PARAMETERS___ [ { "type": "TEXT", "name": "apiKey", "displayName": "Iterable API Key", "simpleValueType": true, "valueValidators": [ { "type": "NON_EMPTY" } ], "alwaysInSummary": true }, { "type": "GROUP", "name": "identitySettingsGroup", "displayName": "Identity Settings", "groupStyle": "ZIPPY_CLOSED", "subParams": [ { "type": "GROUP", "name": "identifiersGroup", "displayName": "Identifiers", "groupStyle": "ZIPPY_OPEN", "subParams": [ { "type": "CHECKBOX", "name": "useClientIdFallback", "checkboxText": "Use client_id for anonymous users", "simpleValueType": true, "defaultValue": true, "alwaysInSummary": true, "help": "Specify whether client_id is used to create a placeholder email for anonymous users." }, { "type": "GROUP", "name": "emailGroup", "displayName": "email", "groupStyle": "ZIPPY_CLOSED", "subParams": [ { "type": "CHECKBOX", "name": "useCommonEmail", "checkboxText": "Use email_address from common user data", "simpleValueType": true, "defaultValue": true, "help": "Use user_data.email_address from the server-side common event as the email identifier of the user.", "alwaysInSummary": true }, { "type": "SIMPLE_TABLE", "name": "email", "displayName": "Specify email", "simpleTableColumns": [ { "defaultValue": "", "displayName": "Search Priority (higher value means higher priority)", "name": "priority", "type": "TEXT", "isUnique": true, "valueValidators": [ { "type": "NON_EMPTY" }, { "type": "NON_NEGATIVE_NUMBER" } ] }, { "defaultValue": "", "displayName": "Property Name or Path", "name": "propPath", "type": "TEXT", "isUnique": true, "valueValidators": [ { "type": "NON_EMPTY" } ] } ], "help": "Specify email Location", "enablingConditions": [ { "paramName": "useCommonEmail", "paramValue": false, "type": "EQUALS" } ], "valueValidators": [ { "type": "NON_EMPTY" } ] } ] }, { "type": "GROUP", "name": "userIdGroup", "displayName": "userId", "groupStyle": "ZIPPY_CLOSED", "subParams": [ { "type": "CHECKBOX", "name": "useCommonUserId", "checkboxText": "Use user_id from common user data", "simpleValueType": true, "defaultValue": true, "help": "Use the user_id property from the server-side common event as the userId identifier of the user.", "alwaysInSummary": true }, { "type": "SIMPLE_TABLE", "name": "userId", "displayName": "Specify userId", "simpleTableColumns": [ { "defaultValue": "", "displayName": "Search Priority (higher value means higher priority)", "name": "priority", "type": "TEXT", "isUnique": true, "valueValidators": [ { "type": "NON_EMPTY" }, { "type": "NON_NEGATIVE_NUMBER" } ] }, { "defaultValue": "", "displayName": "Property Name or Path", "name": "propPath", "type": "TEXT", "valueValidators": [ { "type": "NON_EMPTY" } ], "isUnique": true } ], "enablingConditions": [ { "paramName": "useCommonUserId", "paramValue": false, "type": "EQUALS" } ], "help": "Specify userId Location", "valueValidators": [ { "type": "NON_EMPTY" } ] } ] } ] }, { "type": "GROUP", "name": "identityEventsGroup", "displayName": "Identity Events", "groupStyle": "ZIPPY_OPEN", "subParams": [ { "type": "CHECKBOX", "name": "useDefaultIdentify", "checkboxText": "Use the default `identify` event", "simpleValueType": true, "defaultValue": true, "alwaysInSummary": true }, { "type": "TEXT", "name": "identityEventsByName", "displayName": "Specify identity event(s) by event name", "simpleValueType": true, "textAsList": true, "enablingConditions": [ { "paramName": "useDefaultIdentify", "paramValue": false, "type": "EQUALS" } ], "lineCount": 2 } ] } ] }, { "type": "GROUP", "name": "snowplowEventMapping", "displayName": "Snowplow Event Mapping Options", "groupStyle": "ZIPPY_CLOSED", "subParams": [ { "type": "CHECKBOX", "name": "includeSelfDescribingEvent", "checkboxText": "Include Self Describing Event", "simpleValueType": true, "help": "Indicates if a Snowplow Self Describing event should be in the Iterable events\u0027s dataFields.", "defaultValue": true, "alwaysInSummary": true }, { "type": "GROUP", "name": "entityRules", "displayName": "Snowplow Event Context Rules", "groupStyle": "ZIPPY_CLOSED", "subParams": [ { "type": "CHECKBOX", "name": "extractFromArray", "checkboxText": "Extract entity from array, if single element.", "simpleValueType": true, "defaultValue": true, "help": "Snowplow Entities are always in Arrays, as multiple of the same entity can be attached to an event. This option will pick the single element from the array if the array only contains a single element.", "alwaysInSummary": true }, { "type": "SELECT", "name": "includeEntities", "displayName": "Include Snowplow Entities in event properties", "macrosInSelect": false, "selectItems": [ { "value": "all", "displayValue": "All" }, { "value": "none", "displayValue": "None" } ], "simpleValueType": true, "defaultValue": "all" }, { "type": "SIMPLE_TABLE", "name": "entityMappingRules", "displayName": "Snowplow Entities to Add/Edit mapping", "simpleTableColumns": [ { "defaultValue": "", "displayName": "Entity Name", "name": "key", "type": "TEXT", "isUnique": true, "valueValidators": [ { "type": "NON_EMPTY" } ] }, { "defaultValue": "", "displayName": "Iterable Mapped Name (optional)", "name": "mappedKey", "type": "TEXT", "isUnique": true, "valueHint": "" }, { "defaultValue": "event_properties", "displayName": "Include as event data or user data field", "name": "propertiesObjectToPopulate", "type": "SELECT", "selectItems": [ { "value": "event_properties", "displayValue": "event data" }, { "value": "user_properties", "displayValue": "user data" } ], "valueValidators": [ { "type": "NON_EMPTY" } ] }, { "defaultValue": "control", "displayName": "Apply to all versions", "name": "version", "type": "SELECT", "valueValidators": [ { "type": "NON_EMPTY" } ], "selectItems": [ { "value": "control", "displayValue": "False" }, { "value": "free", "displayValue": "True" } ] } ], "enablingConditions": [], "help": "Specifiy the Entity name from the GTM Event, and the key you could like to map it to or leave the mapped key blank to keep the same name. Additionally specify whether to add in event properties or user properties object of the Iterable payload and whether you wish the mapping to apply to all versions of the entity.", "valueValidators": [] }, { "type": "SIMPLE_TABLE", "name": "entityExclusionRules", "displayName": "Snowplow Entities to Exclude", "simpleTableColumns": [ { "defaultValue": "", "displayName": "Entity Name", "name": "key", "type": "TEXT", "isUnique": true, "valueValidators": [ { "type": "NON_EMPTY" } ] }, { "defaultValue": "control", "displayName": "Apply to all versions", "name": "version", "type": "SELECT", "valueValidators": [ { "type": "NON_EMPTY" } ], "selectItems": [ { "value": "control", "displayValue": "False" }, { "value": "free", "displayValue": "True" } ] } ], "enablingConditions": [ { "paramName": "includeEntities", "paramValue": "all", "type": "EQUALS" } ], "help": "Specify the Entity name you want to exclude from the Iterable event. Additionally, you can also set whether the exclusion applies to all versions of the entity." } ] } ] }, { "type": "GROUP", "name": "otherEventMapping", "displayName": "Additional Event Mapping Options", "groupStyle": "ZIPPY_CLOSED", "subParams": [ { "type": "GROUP", "name": "commonEventPropertyRules", "displayName": "Event Property Rules", "groupStyle": "ZIPPY_OPEN", "subParams": [ { "type": "CHECKBOX", "name": "includeCommonEventProperties", "checkboxText": "Include common event properties", "simpleValueType": true, "help": "Include the event properties from the common event definition in the Iterable event dataFields.", "defaultValue": true, "alwaysInSummary": true }, { "type": "SIMPLE_TABLE", "name": "eventMappingRules", "displayName": "Additional Event Properties Mapping Rules", "simpleTableColumns": [ { "defaultValue": "", "displayName": "Event Property Key", "name": "key", "type": "TEXT", "isUnique": true, "valueValidators": [ { "type": "NON_EMPTY" } ] }, { "defaultValue": "", "displayName": "Iterable Mapped Key (optional)", "name": "mappedKey", "type": "TEXT", "isUnique": true } ], "help": "Specify the Property Key from the GTM Event, and the key you could like to map it to or leave the mapped key blank to keep the same name. You can use Key Path notation here (e.g. `x-sp-tp2.p` for a Snowplow events platform or `x-sp-contexts.com_snowplowanalytics_snowplow_web_page_1.0.id` for a Snowplow events page view id (in array index 0). These keys will populate the Iterable event\u0027s dataFields object." } ] }, { "type": "GROUP", "name": "commonUserPropertyRules", "displayName": "User Property Rules", "groupStyle": "ZIPPY_OPEN", "subParams": [ { "type": "CHECKBOX", "name": "includeCommonUserProperties", "checkboxText": "Include common user properties", "simpleValueType": true, "defaultValue": true, "alwaysInSummary": true, "help": "Include the user_data properties from the common event definition in the Iterable user dataFields." }, { "type": "SIMPLE_TABLE", "name": "userMappingRules", "displayName": "Additional User Property Mapping Rules", "simpleTableColumns": [ { "defaultValue": "", "displayName": "Event Property Key", "name": "key", "type": "TEXT", "isUnique": true, "valueValidators": [ { "type": "NON_EMPTY" } ] }, { "defaultValue": "", "displayName": "Iterable Mapped Key (optional)", "name": "mappedKey", "type": "TEXT", "isUnique": true } ], "help": "Specifiy the Property Key from the GTM Event, and then key you could like to map it to or leave the mapped key blank to keep the same name. You can use Key Path notation here (e.g. `x-sp-tp2.p` for a Snowplow events platform or `x-sp-contexts.com_snowplowanalytics_snowplow_web_page_1.0.id` for a Snowplow events page view id (in array index 0). These keys will populate the Iterable user dataFields object." } ] } ] }, { "type": "GROUP", "name": "advancedProperties", "displayName": "Advanced Event Settings", "groupStyle": "ZIPPY_CLOSED", "subParams": [ { "type": "CHECKBOX", "name": "mergeNestedUserData", "checkboxText": "Merge user dataFields when updating an Iterable user.", "simpleValueType": true, "defaultValue": false, "help": "Setting to true means that on every update the nested user dataFields are merged instead of replaced." } ] }, { "type": "GROUP", "name": "logsGroup", "displayName": "Logs Settings", "groupStyle": "ZIPPY_CLOSED", "subParams": [ { "type": "RADIO", "name": "logType", "displayName": "", "radioItems": [ { "value": "no", "displayValue": "Do not log", "help": "This option completely disables logging." }, { "value": "debug", "displayValue": "Log to console during debug and preview", "help": "This option allows logging only in debug and preview containers. Please consider that the logs produced include event data." }, { "value": "always", "displayValue": "Always log to console", "help": "This option enables logging in any container. Please consider that when enabled, the logs produced include event data." } ], "simpleValueType": true, "defaultValue": "debug" } ] } ] ___SANDBOXED_JS_FOR_SERVER___ const createRegex = require('createRegex'); const getAllEventData = require('getAllEventData'); const getContainerVersion = require('getContainerVersion'); const getEventData = require('getEventData'); const getRequestHeader = require('getRequestHeader'); const getTimestampMillis = require('getTimestampMillis'); const getType = require('getType'); const JSON = require('JSON'); const log = require('logToConsole'); const makeInteger = require('makeInteger'); const makeNumber = require('makeNumber'); const sendHttpRequest = require('sendHttpRequest'); const sha256Sync = require('sha256Sync'); // Constants const tagName = 'Iterable'; const iterableApi = 'https://api.iterable.com/api/'; const iterableEndpoints = { usersUpdate: iterableApi + 'users/update', eventsTrack: iterableApi + 'events/track', }; const iterablePlaceholderDomain = '@placeholder.email'; const defaultIds = { email: [ { priority: 0, propPath: 'user_data.email_address', }, ], userId: [ { priority: 0, propPath: 'user_id', }, ], }; // Helpers /** * Assumes logType argument is string. * Determines if logging is enabled. * * @param {string} logType - The logType set ('no', 'debug', 'always') * @returns {boolean} Whether logging is enabled */ const determineIsLoggingEnabled = (logType) => { const containerVersion = getContainerVersion(); const isDebugMode = !!( containerVersion && (containerVersion.debugMode || containerVersion.previewMode) ); if (!logType) { return isDebugMode; } if (data.logType === 'no') { return false; } if (data.logType === 'debug') { return isDebugMode; } return data.logType === 'always'; }; /** * Creates the log message and logs it to console. * * @param {string} typeName - The type of log ('Message', 'Request', 'Response') * @param {Object} stdInfo - The standard info for all logs (Name, Type, TraceId, EventName) * @param {Object} logInfo - An object including information for the specific log type * @returns {undefined} */ const doLogging = (typeName, stdInfo, logInfo) => { const logMessage = { Name: stdInfo.tagName, Type: typeName, TraceId: stdInfo.traceId, EventName: stdInfo.eventName, }; switch (typeName) { case 'Message': logMessage.Message = logInfo.msg; break; case 'Request': logMessage.RequestMethod = logInfo.requestMethod; logMessage.RequestUrl = logInfo.requestUrl; logMessage.RequestHeaders = logInfo.requestHeaders; logMessage.RequestBody = logInfo.requestBody; break; case 'Response': logMessage.ResponseStatusCode = logInfo.responseStatusCode; logMessage.ResponseHeaders = logInfo.responseHeaders; logMessage.ResponseBody = logInfo.responseBody; break; default: // do nothing return; } log(JSON.stringify(logMessage)); }; /** * Removes equal to null properties from given object. * * @param {Object} obj - The object to clean * @returns {Object} */ const cleanObject = (obj) => { let target = {}; for (let prop in obj) { if (obj.hasOwnProperty(prop) && obj[prop] != null) { target[prop] = obj[prop]; } } return target; }; /** * Merges objects. * * @param {Object[]} args - The array of objects to merge * @returns {Object} The resulting object */ const merge = (args) => { let target = {}; const addToTarget = (obj) => { for (let prop in obj) { if (obj.hasOwnProperty(prop)) { target[prop] = obj[prop]; } } }; for (let i = 0; i < args.length; i++) { addToTarget(args[i]); } return target; }; /** * Utility function that creates an object according to Event Property Rules. * * @param {Object[]} configProps - The event property rules * @returns {Object} */ const getEventDataByKeys = (configProps) => { const props = {}; configProps.forEach((p) => { let eventProperty = getEventData(p.key); if (eventProperty !== undefined) { props[p.mappedKey || p.key] = eventProperty; } }); return props; }; /** * Returns whether a string is upper case. * * @param {string} value - The string to check * @returns {boolean} */ const isUpper = (value) => { return value === value.toUpperCase() && value !== value.toLowerCase(); }; /** * Converts a string to snake case. * * @param {string} value - The string to convert * @returns {string} The converted string */ const toSnakeCase = (value) => { let result = ''; let previousChar; for (var i = 0; i < value.length; i++) { let currentChar = value.charAt(i); if (isUpper(currentChar) && i > 0 && previousChar !== '_') { result = result + '_' + currentChar; } else { result = result + currentChar; } previousChar = currentChar; } return result; }; /** * Cleans a name from the GTM-SS Snowplow prefix ('x-sp-'). * * @param {string} prop - The property name * @returns {string} The property name with the GTM-SS Snowplow prefix removed. */ const cleanPropertyName = (prop) => prop.replace('x-sp-', ''); /** * Given an array and a configuration object, * returns the element from a single element array or the array itself. * * @param {Array} arr - The input array * @param {Object} tagConfig - The tag configuration object * @param {boolean} tagConfig.extractFromArray - Whether to extract a single element * @returns {*} The array or its single element */ const extractFromArrayIfSingleElement = (arr, tagConfig) => arr.length === 1 && tagConfig.extractFromArray ? arr[0] : arr; /** * Parses a Snowplow schema to the expected major version format, * also prefixed so as to match the contexts' output of the Snowplow Client. * * @param {string} schema - The input schema * @returns {string} The expected output client event property */ const parseSchemaToMajorKeyValue = (schema) => { if (schema.indexOf('x-sp-contexts_') === 0) return schema; if (schema.indexOf('contexts_') === 0) return 'x-sp-' + schema; if (schema.indexOf('iglu:') === 0) { const rexp = createRegex('[./]', 'g'); let fixed = schema .replace('iglu:', '') .replace('jsonschema/', '') .replace(rexp, '_'); for (let i = 0; i < 2; i++) { fixed = fixed.substring(0, fixed.lastIndexOf('-')); } return 'x-sp-contexts_' + toSnakeCase(fixed).toLowerCase(); } return schema; }; /** * Returns whether a property name is a Snowplow self-describing event property. * * @param {string} prop - The property name * @returns {boolean} */ const isSpSelfDescProp = (prop) => { return prop.indexOf('x-sp-self_describing_event_') === 0; }; /** * Returns whether a property name is a Snowplow context/entity property. * * @param {string} prop - The property name * @returns {boolean} */ const isSpContextsProp = (prop) => { return prop.indexOf('x-sp-contexts_') === 0; }; /** * Removes the major version part from a schema reference if exists. * @example * // returns 'com_acme_test' * mkVersionFree('com_acme_test_1') * @example * // returns 'com_acme_test' * mkVersionFree('com_acme_test') * * @param {string} schemaRef - The schema * @returns {string} */ const mkVersionFree = (schemaRef) => { const versionRexp = createRegex('_[0-9]+$'); return schemaRef.replace(versionRexp, ''); }; /** * Given a list of entity references and an entity name, * returns the index of a matching reference. * Matching reference means whether the entity name starts with ref. * @example * // returns 0 * getReferenceIdx('com_test_test_1', ['com_test_test_1']); * @example * // returns 0 * getReferenceIdx('com_test_test_1', ['com_test_test']); * @example * // returns -1 * getReferenceIdx('com_test_test_1', ['com_test_test_2']); * @example * // returns -1 * getReferenceIdx('com_test_test', ['com_test_test_fail']); * @example * // returns -1 * getReferenceIdx('com_test_test_fail', ['com_test_test']); * * @param {string} entity - The entity name to match * @param {string[]} refsList - An array of references * @returns {integer} */ const getReferenceIdx = (entity, refsList) => { const versionFreeEntity = mkVersionFree(entity); for (let i = 0; i < refsList.length; i++) { const okControl = entity.indexOf(refsList[i]) === 0; const okFree = versionFreeEntity === mkVersionFree(refsList[i]); if (okControl && okFree) { return i; } } return -1; }; /** * Filters out invalid rules to avoid unintended behavior. * (e.g. version control being ignored if version num is not included in name) * Assumes that a rule contains 'key' and 'version' properties. * * @param {Object[]} rules - The provided rules * @returns {Object[]} The valid rules */ const cleanRules = (rules) => { const lastNumRexp = createRegex('[0-9]$'); return rules.filter((row) => { if (row.version === 'control') { return !!row.key.match(lastNumRexp); } return true; }); }; /** * Parses the entity exclusion rules from the tag configuration. * * @param {Object} tagConfig - The tag configuration * @returns {Object[]} */ const parseEntityExclusionRules = (tagConfig) => { const rules = tagConfig.entityExclusionRules; if (rules) { const validRules = cleanRules(rules); const excludedEntities = validRules.map((row) => { const entityRef = parseSchemaToMajorKeyValue(row.key); return { ref: row.version === 'control' ? entityRef : mkVersionFree(entityRef), version: row.version, }; }); return excludedEntities; } return []; }; /** * Parses the entity inclusion rules from the tag configuration. * * @param {Object} tagConfig - The tag configuration * @returns {Object[]} */ const parseEntityRules = (tagConfig) => { const rules = tagConfig.entityMappingRules; if (rules) { const validRules = cleanRules(rules); const parsedRules = validRules.map((row) => { const parsedKey = parseSchemaToMajorKeyValue(row.key); return { ref: row.version === 'control' ? parsedKey : mkVersionFree(parsedKey), parsedKey: parsedKey, mappedKey: row.mappedKey || cleanPropertyName(parsedKey), target: row.propertiesObjectToPopulate, version: row.version, }; }); return parsedRules; } return []; }; /** * Given the inclusion rules and the excluded entity references, * returns the final entity mapping rules. * * @param {Object[]} inclusionRules - The rules about entities to include * @param {string[]} excludedRefs - The entity references to be excluded * @returns {Object[]} The final entity rules */ const finalizeEntityRules = (inclusionRules, excludedRefs) => { const finalEntities = inclusionRules.filter((row) => { const refIdx = getReferenceIdx(row.ref, excludedRefs); return refIdx < 0; }); return finalEntities; }; /** * Constructs the respective event and user properties. * * @param {Object} eventData - The client event object * @param {Object} tagConfig - The tag configuration object * @returns {Object} */ const parseCustomEventsAndEntities = (eventData, tagConfig) => { const inclusionRules = parseEntityRules(tagConfig); const exclusionRules = parseEntityExclusionRules(tagConfig); const excludedRefs = exclusionRules.map((r) => r.ref); const finalEntityRules = finalizeEntityRules(inclusionRules, excludedRefs); const finalEntityRefs = finalEntityRules.map((r) => r.ref); const eventProperties = {}; const userProperties = {}; for (let prop in eventData) { if (eventData.hasOwnProperty(prop)) { const cleanPropName = cleanPropertyName(prop); if (isSpSelfDescProp(prop) && tagConfig.includeSelfDescribingEvent) { eventProperties[cleanPropName] = eventData[prop]; continue; } if (isSpContextsProp(prop)) { if (getReferenceIdx(prop, excludedRefs) >= 0) { continue; } const ctxVal = extractFromArrayIfSingleElement( eventData[prop], tagConfig ); const refIdx = getReferenceIdx(prop, finalEntityRefs); if (refIdx >= 0) { const rule = finalEntityRules[refIdx]; const target = rule.target === 'event_properties' ? eventProperties : userProperties; target[rule.mappedKey] = ctxVal; } else { if (tagConfig.includeEntities === 'none') { continue; } // here includedEntities is 'all' and prop is not excluded eventProperties[cleanPropName] = ctxVal; } } } } return { event: eventProperties, user: userProperties, }; }; /** * Creates the `dataFields` for the Iterable payload towards /events/track API. * * @param {Object} eventData - The common event object * @param {Object} tagConfig - The tag configuration * @param {Object} eventSpFields - The Snowplow fields to add * @returns {Object} */ const mkEventDataFields = (eventData, tagConfig, eventSpFields) => { let eventProperties = {}; if (tagConfig.includeCommonEventProperties) { eventProperties.page_location = eventData.page_location; eventProperties.page_encoding = eventData.page_encoding; eventProperties.page_referrer = eventData.page_referrer; eventProperties.page_title = eventData.page_title; eventProperties.screen_resolution = eventData.screen_resolution; eventProperties.viewport_size = eventData.viewport_size; } if (tagConfig.eventMappingRules && tagConfig.eventMappingRules.length > 0) { eventProperties = merge([ eventProperties, getEventDataByKeys(tagConfig.eventMappingRules), ]); } const final = merge([eventProperties, eventSpFields]); return cleanObject(final); }; /** * Creates the `dataFields` for the Iterable payload towards /users/update API. * * @param {Object} eventData - The common event object * @param {Object} tagConfig - The tag configuration * @param {Object} userSpFields - The Snowplow fields to add * @returns {Object} */ const mkUserDataFields = (eventData, tagConfig, userSpFields) => { let userProperties = {}; if (tagConfig.includeCommonUserProperties && eventData.user_data) { // not adding user_data.email_address, to avoid redundant reference to email userProperties.phone_number = eventData.user_data.phone_number; userProperties.address = eventData.user_data.address; } if (tagConfig.userMappingRules && tagConfig.userMappingRules.length > 0) { userProperties = merge([ userProperties, getEventDataByKeys(tagConfig.userMappingRules), ]); } const final = merge([userProperties, userSpFields]); return cleanObject(final); }; /** * Helper function to locate a user identifier from a given table (locator). * A locator is a table (array of objects) like the `email` and `userId` tables, * which are Tag configuration Fields. * Each of its rows contains a priority and an event key path to look for. * * @param {Object[]} locator - The locator array * @returns {*} */ const locate = (locator) => { const ordLoc = locator.sort((x, y) => { const xPriority = makeInteger(x.priority); const yPriority = makeInteger(y.priority); return xPriority > yPriority ? -1 : 1; }); for (let i = 0; i < ordLoc.length; i++) { const row = ordLoc[i]; const located = getEventData(row.propPath); if (located) { return located; } } return undefined; }; /** * Determines if an event is an identity event depending on Tag configuration. * * @param {Object} eventData - The common event object * @param {Object} tagConfig - The tag configuration * @returns {boolean} */ const isIdentityEvent = (eventData, tagConfig) => { if (tagConfig.useDefaultIdentify) { return eventData.event_name === 'identify'; } const identityEvents = tagConfig.identityEventsByName; if (getType(identityEvents) === 'array') { return identityEvents.indexOf(eventData.event_name) >= 0; } return false; }; /** * Creates an identifiers object, which has properties: * - email * - userId * See also: iglu:com.snowplowanalytics.snowplow/identify/jsonschema/1-0-0 * * @param {Object} eventData - The common event object * @param {Object} tagConfig - The tag configuration * @returns {Object} */ const mkIdentifiers = (eventData, tagConfig) => { const idLocations = { email: tagConfig.useCommonEmail ? defaultIds.email : tagConfig.email, userId: tagConfig.useCommonUserId ? defaultIds.userId : tagConfig.userId, }; const userIdentifiers = { email: locate(idLocations.email), userId: locate(idLocations.userId), }; return cleanObject(userIdentifiers); }; /** * Creates the Iterable payload for /events/track API. * * @param {Object} eventData - The common event object * @param {Object} tagConfig - The tag configuration * @param {Object} ids - The identifiers object * @param {Object} eventSpFields - The dataFields from Snowplow Event Mapping Options * @returns {Object} The payload object */ const mkIterableEvent = (eventData, tagConfig, ids, eventSpFields) => { const eventBody = { email: ids.email, userId: ids.userId, eventName: eventData.event_name, id: eventData['x-sp-event_id'] || sha256Sync( eventData.event_name + eventData.client_id + getTimestampMillis() ), dataFields: mkEventDataFields(eventData, tagConfig, eventSpFields), }; return cleanObject(eventBody); }; /** * Creates the Iterable payload for /users/update API. * * @param {Object} eventData - The common event object * @param {Object} tagConfig - The tag configuration * @param {Object} ids - The identifiers object * @param {Object} userSpFields - The dataFields from Snowplow Event Mapping Options * @returns {Object} The payload object */ const mkIterableUserData = (eventData, tagConfig, ids, userSpFields) => { const userDataBody = { email: ids.email, userId: ids.userId, preferUserId: false, mergeNestedObjects: tagConfig.mergeNestedUserData, dataFields: mkUserDataFields(eventData, tagConfig, userSpFields), }; return cleanObject(userDataBody); }; /** * Determines if an API response means 'No user exists with userId'. * * @param {string} - The request body * @returns {boolean} */ const isNoUidResponse = (body) => { const checkCode = 'BadParams'; const checkMsg = 'No user exists with userId'; const respBody = JSON.parse(body); return respBody.code === checkCode && respBody.msg.indexOf(checkMsg) === 0; }; /** * Given an iterable payload: * - creates a placeholder email from userId (assumes userId exists) * - adds it in the payload (side effects) * * @param {Object} iterableData * @returns {Object} The Iterable payload with added placeholder email */ const addPlaceholderEmail = (iterableData) => { const placeholderEmail = iterableData.userId + iterablePlaceholderDomain; iterableData.email = placeholderEmail; return iterableData; }; /** * Tracks an event using userId as user identifier. * If it fails because no user exists with that userId, * makes placeholder email and tracks the event. * * @param {Object} iterableEvent - The Iterable event to track * @param {Object} opts - The request options * @param {function} logFun - The function to use for logging */ const trackWithUserIdPath = (iterableEvent, opts, logFun) => { logFun('Request', { requestMethod: opts.method, requestUrl: iterableEndpoints.eventsTrack, requestBody: iterableEvent, }); sendHttpRequest( iterableEndpoints.eventsTrack, (statusCode, headers, body) => { logFun('Response', { responseStatusCode: statusCode, responseHeaders: headers, responseBody: body, }); if (statusCode >= 200 && statusCode < 300) { return data.gtmOnSuccess(); } if (isNoUidResponse(body)) { const updatedIterableEvent = addPlaceholderEmail(iterableEvent); return trackWithEmailPath(updatedIterableEvent, opts, logFun); } return data.gtmOnFailure(); }, opts, JSON.stringify(iterableEvent) ); }; /** * Tracks an event using email as user identifier. * * @param {Object} iterableEvent - The Iterable event to track * @param {Object} opts - The request options * @param {function} logFun - The function to use for logging */ const trackWithEmailPath = (iterableEvent, opts, logFun) => { logFun('Request', { requestMethod: opts.method, requestUrl: iterableEndpoints.eventsTrack, requestBody: iterableEvent, }); sendHttpRequest( iterableEndpoints.eventsTrack, (statusCode, headers, body) => { logFun('Response', { responseStatusCode: statusCode, responseHeaders: headers, responseBody: body, }); if (statusCode >= 200 && statusCode < 300) { return data.gtmOnSuccess(); } return data.gtmOnFailure(); }, opts, JSON.stringify(iterableEvent) ); }; /** * Makes placeholder email from userId and hands over to updateWithEmailPath. * * @param {Object} iterableEvent - The Iterable event to track * @param {Object} iterableUserData - The Iterable user data * @param {Object} httpOptions - The request options * @param {function} logFun - The function to use for logging */ const updateWithUserIdPath = ( iterableEvent, iterableUserData, httpOptions, logFun ) => { const updatedEvent = addPlaceholderEmail(iterableEvent); const updatedUserData = addPlaceholderEmail(iterableUserData); return updateWithEmailPath(updatedEvent, updatedUserData, httpOptions); }; /** * Updates the user by email and then tracks the event. * * @param {Object} iterableEvent - The Iterable event to track * @param {Object} iterableUserData - The Iterable user data * @param {Object} httpOptions - The request options * @param {function} logFun - The function to use for logging */ const updateWithEmailPath = ( iterableEvent, iterableUserData, httpOptions, logFun ) => { logFun('Request', { requestMethod: httpOptions.method, requestUrl: iterableEndpoints.usersUpdate, requestBody: iterableUserData, }); sendHttpRequest( iterableEndpoints.usersUpdate, (statusCode, headers, body) => { logFun('Response', { responseStatusCode: statusCode, responseHeaders: headers, responseBody: body, }); if (statusCode >= 200 && statusCode < 300) { return trackWithEmailPath(iterableEvent, httpOptions, logFun); } return data.gtmOnFailure(); }, httpOptions, JSON.stringify(iterableUserData) ); }; /** * Creates the HTTP request options for Iterable API. * * @param {Object} tagConfig - The tag configuration * @param {boolean} redact - Whether to redact api key * @returns {Object} The HTTP options object */ const mkRequestOptions = (tagConfig, redact) => { const apiKey = redact ? 'redacted' : tagConfig.apiKey; return { method: 'POST', timeout: 5000, headers: { 'api-key': apiKey, 'Content-Type': 'application/json', }, }; }; /** * Helper that wraps doLogging and returns a function that closes over the common * logging information. * If logging is disabled, returns a no-op function. * * @param {boolean} enabled - Whether logging is enabled * @param {Object} stdInfo - The standard info for all logs (Name, Type, TraceId, EventName) * @param {Object} invariantInfo - Contains common information outside of stdInfo * @returns {function} */ const mkLogger = (enabled, stdInfo, invariantInfo) => { if (!enabled) { return function (logName, logInfo) { return; // no-op }; } return function (logName, logInfo) { const updatedLogInfo = merge([invariantInfo, logInfo]); return doLogging(logName, stdInfo, updatedLogInfo); }; }; // Main const eventData = getAllEventData(); const loggingEnabled = determineIsLoggingEnabled(data.logType); const traceIdHeader = loggingEnabled ? getRequestHeader('trace-id') : undefined; const stdLogInfo = { tagName: tagName, traceId: traceIdHeader, eventName: eventData.event_name, }; const httpOptions = mkRequestOptions(data, false); const redactedHttpOptions = mkRequestOptions(data, true); const logging = mkLogger(loggingEnabled, stdLogInfo, { requestHeaders: redactedHttpOptions.headers, }); const identifiers = mkIdentifiers(eventData, data); if (!(identifiers.email || identifiers.userId)) { if (data.useClientIdFallback) { identifiers.email = eventData.client_id + iterablePlaceholderDomain; identifiers.userId = eventData.client_id; } else { logging('Message', { msg: 'Unable to derive user identifiers.' }); return data.gtmOnFailure(); } } const spRules = parseCustomEventsAndEntities(eventData, data); const iterableEvent = mkIterableEvent( eventData, data, identifiers, spRules.event ); if (isIdentityEvent(eventData, data)) { const iterableUserData = mkIterableUserData( eventData, data, identifiers, spRules.user ); if (identifiers.email) { return updateWithEmailPath( iterableEvent, iterableUserData, httpOptions, logging ); } else { return updateWithUserIdPath( iterableEvent, iterableUserData, httpOptions, logging ); } } else { if (identifiers.email) { return trackWithEmailPath(iterableEvent, httpOptions, logging); } else { return trackWithUserIdPath(iterableEvent, httpOptions, logging); } } ___SERVER_PERMISSIONS___ [ { "instance": { "key": { "publicId": "read_event_data", "versionId": "1" }, "param": [ { "key": "eventDataAccess", "value": { "type": 1, "string": "any" } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true }, { "instance": { "key": { "publicId": "send_http", "versionId": "1" }, "param": [ { "key": "allowedUrls", "value": { "type": 1, "string": "any" } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true }, { "instance": { "key": { "publicId": "logging", "versionId": "1" }, "param": [ { "key": "environments", "value": { "type": 1, "string": "all" } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true }, { "instance": { "key": { "publicId": "read_request", "versionId": "1" }, "param": [ { "key": "headerWhitelist", "value": { "type": 2, "listItem": [ { "type": 3, "mapKey": [ { "type": 1, "string": "headerName" } ], "mapValue": [ { "type": 1, "string": "trace-id" } ] } ] } }, { "key": "headersAllowed", "value": { "type": 8, "boolean": true } }, { "key": "requestAccess", "value": { "type": 1, "string": "specific" } }, { "key": "headerAccess", "value": { "type": 1, "string": "specific" } }, { "key": "queryParameterAccess", "value": { "type": 1, "string": "any" } } ] }, "clientAnnotations": { "isEditedByUser": true }, "isRequired": true }, { "instance": { "key": { "publicId": "read_container_data", "versionId": "1" }, "param": [] }, "isRequired": true } ] ___TESTS___ scenarios: - name: Test defaults code: | const mockData = { apiKey: 'test', useClientIdFallback: true, useCommonEmail: true, useCommonUserId: true, useDefaultIdentify: true, includeSelfDescribingEvent: true, extractFromArray: true, includeEntities: 'all', includeCommonEventProperties: true, includeCommonUserProperties: true, mergeNestedUserData: false, logType: 'debug', }; const testEvent = mockEventObjectPageView; const expectedIterableBody = { email: 'foo@example.com', userId: 'snow123', eventName: 'page_view', id: '8676de79-0eba-4435-ad95-8a41a8a0129c', dataFields: { page_location: 'https://snowplow.io/', page_encoding: 'UTF-8', page_referrer: 'referer', page_title: 'Collect, manage and operationalize behavioral data at scale | Snowplow', screen_resolution: '1920x1080', viewport_size: '745x1302', contexts_com_snowplowanalytics_snowplow_web_page_1: { id: 'a86c42e5-b831-45c8-b706-e214c26b4b3d', }, contexts_org_w3_performance_timing_1: { navigationStart: 1628586508610, unloadEventStart: 0, unloadEventEnd: 0, redirectStart: 0, redirectEnd: 0, fetchStart: 1628586508610, domainLookupStart: 1628586508637, domainLookupEnd: 1628586508691, connectStart: 1628586508691, connectEnd: 1628586508763, secureConnectionStart: 1628586508721, requestStart: 1628586508763, responseStart: 1628586508797, responseEnd: 1628586508821, domLoading: 1628586509076, domInteractive: 1628586509381, domContentLoadedEventStart: 1628586509408, domContentLoadedEventEnd: 1628586509417, domComplete: 1628586510332, loadEventStart: 1628586510332, loadEventEnd: 1628586510334, }, 'contexts_com_google_tag-manager_server-side_user_data_1': { email_address: 'foo@example.com', phone_number: '+15551234567', address: { first_name: 'Jane', last_name: 'Doe', street: '123 Fake St', city: 'San Francisco', region: 'CA', postal_code: '94016', country: 'US', }, }, }, }; // to assert on let argUrl, argCallback, argOptions, argBody; // Mocks mock('sendHttpRequest', function () { // mock response const respStatusCode = 200; const respHeaders = { foo: 'bar' }; const respBody = 'ok'; argUrl = arguments[0]; argCallback = arguments[1]; argOptions = arguments[2]; argBody = arguments[3]; // and call the callback with mock response argCallback(respStatusCode, respHeaders, respBody); }); mock('getContainerVersion', function () { let containerVersion = { // debug container debugMode: false, previewMode: true, }; return containerVersion; }); mock('getAllEventData', function () { return testEvent; }); mock('getEventData', function (x) { return getFromPath(x, testEvent); }); // Call runCode to run the template's code runCode(mockData); // Assert assertApi('sendHttpRequest').wasCalled(); assertThat(argUrl).isStrictlyEqualTo(trackURL); assertThat(argOptions.method).isStrictlyEqualTo('POST'); assertThat(argOptions.timeout).isStrictlyEqualTo(5000); assertThat(argOptions.headers['Content-Type']).isStrictlyEqualTo( 'application/json' ); const body = jsonApi.parse(argBody); assertThat(body).isEqualTo(expectedIterableBody); // assert 'debug' does log in debug assertApi('logToConsole').wasCalled(); - name: Test identifier settings code: | const mockData = { apiKey: 'test', useClientIdFallback: true, useCommonEmail: false, email: [ { priority: '100', propPath: 'x-sp-contexts_com_google_tag-manager_server-side_user_data_1.0.email_address', }, ], useCommonUserId: false, userId: [ { priority: '90', propPath: 'x-sp-contexts_com_google_tag-manager_server-side_user_data_1.0.email_address' }, { priority: '100', propPath: 'user_id' } ], useDefaultIdentify: false, identityEventsByName: ['sign_up'], includeSelfDescribingEvent: true, extractFromArray: true, includeEntities: 'all', includeCommonEventProperties: true, includeCommonUserProperties: true, mergeNestedUserData: false, logType: 'no', }; const testEvent = mockEventObjectSignUp; // identity event const expectedIterableUpdateBody = { email: 'foo@bar.baz', userId: 'testUser', preferUserId: false, mergeNestedObjects: false, dataFields: { phone_number: '+15551234567', address: { first_name: 'Jane', last_name: 'Doe', street: '123 Fake St', city: 'San Francisco', region: 'CA', postal_code: '94016', country: 'US', }, }, }; const expectedIterableTrackBody = { email: 'foo@bar.baz', userId: 'testUser', eventName: 'sign_up', id: '6926e78c-36ea-4424-b9d0-a014dca402de', dataFields: { page_location: 'http://localhost:8000/', page_encoding: 'UTF-8', screen_resolution: '1920x1080', viewport_size: '779x975', 'self_describing_event_com_google_tag-manager_server-side_sign_up_1': { method: 'fooMethod', }, 'contexts_com_google_tag-manager_server-side_user_data_1': { email_address: 'foo@bar.baz', phone_number: '+15551234567', address: { first_name: 'Jane', last_name: 'Doe', street: '123 Fake St', city: 'San Francisco', region: 'CA', postal_code: '94016', country: 'US', }, }, contexts_com_snowplowanalytics_snowplow_web_page_1: { id: '083ecbbc-da82-45e4-b76f-0b3f1902a7da', }, }, }; // to assert on let updUrl, updCallback, updOptions, updBody; let trUrl, trCallback, trOptions, trBody; // Mocks mock('sendHttpRequest', function () { // mock response const respStatusCode = 200; const respHeaders = { foo: 'bar' }; const respBody = 'ok'; if (arguments[0] === updateURL) { updUrl = arguments[0]; updCallback = arguments[1]; updOptions = arguments[2]; updBody = arguments[3]; // and call the update callback with mock response updCallback(respStatusCode, respHeaders, respBody); } else { trUrl = arguments[0]; trCallback = arguments[1]; trOptions = arguments[2]; trBody = arguments[3]; // and call the track callback with mock response trCallback(respStatusCode, respHeaders, respBody); } }); mock('getContainerVersion', function () { let containerVersion = { // prod container debugMode: false, previewMode: false, }; return containerVersion; }); mock('getAllEventData', function () { return testEvent; }); mock('getEventData', function (x) { return getFromPath(x, testEvent); }); // Call runCode to run the template's code runCode(mockData); // Assert // In this test the input is an identity event // so we expect sendHttpRequest to have been called twice. assertApi('sendHttpRequest').wasCalled(); assertApi('sendHttpRequest').wasCalledWith( updUrl, updCallback, updOptions, updBody ); assertApi('sendHttpRequest').wasCalledWith( trUrl, trCallback, trOptions, trBody ); assertThat(updUrl).isStrictlyEqualTo(updateURL); assertThat(trUrl).isStrictlyEqualTo(trackURL); assertThat(updOptions.method).isStrictlyEqualTo('POST'); assertThat(updOptions.timeout).isStrictlyEqualTo(5000); assertThat(updOptions.headers['Content-Type']).isStrictlyEqualTo( 'application/json' ); assertThat(trOptions.method).isStrictlyEqualTo('POST'); assertThat(trOptions.timeout).isStrictlyEqualTo(5000); assertThat(trOptions.headers['Content-Type']).isStrictlyEqualTo( 'application/json' ); const updateBody = jsonApi.parse(updBody); assertThat(updateBody).isEqualTo(expectedIterableUpdateBody); const trackBody = jsonApi.parse(trBody); assertThat(trackBody).isEqualTo(expectedIterableTrackBody); // assert 'no' does not log in prod assertApi('logToConsole').wasNotCalled(); - name: Test identifier settings - useClientIdFallback code: | const mockData = { apiKey: 'test', useClientIdFallback: true, useCommonEmail: false, email: [ { priority: '100', propPath: 'doesNotExist', }, ], useCommonUserId: false, userId: [ { priority: '100', propPath: 'doesNotExist', }, ], useDefaultIdentify: false, identityEventsByName: ['sign_up'], includeSelfDescribingEvent: true, extractFromArray: true, includeEntities: 'all', includeCommonEventProperties: true, includeCommonUserProperties: true, mergeNestedUserData: false, logType: 'no', }; const testEvent = mockEventObjectSignUp; // identity event const expectedIterableUpdateBody = { email: '443cffcd-5d44-4b64-9606-df47f4fa821e@placeholder.email', userId: '443cffcd-5d44-4b64-9606-df47f4fa821e', preferUserId: false, mergeNestedObjects: false, dataFields: { phone_number: '+15551234567', address: { first_name: 'Jane', last_name: 'Doe', street: '123 Fake St', city: 'San Francisco', region: 'CA', postal_code: '94016', country: 'US', }, }, }; const expectedIterableTrackBody = { email: '443cffcd-5d44-4b64-9606-df47f4fa821e@placeholder.email', userId: '443cffcd-5d44-4b64-9606-df47f4fa821e', eventName: 'sign_up', id: '6926e78c-36ea-4424-b9d0-a014dca402de', dataFields: { page_location: 'http://localhost:8000/', page_encoding: 'UTF-8', screen_resolution: '1920x1080', viewport_size: '779x975', 'self_describing_event_com_google_tag-manager_server-side_sign_up_1': { method: 'fooMethod', }, 'contexts_com_google_tag-manager_server-side_user_data_1': { email_address: 'foo@bar.baz', phone_number: '+15551234567', address: { first_name: 'Jane', last_name: 'Doe', street: '123 Fake St', city: 'San Francisco', region: 'CA', postal_code: '94016', country: 'US', }, }, contexts_com_snowplowanalytics_snowplow_web_page_1: { id: '083ecbbc-da82-45e4-b76f-0b3f1902a7da', }, }, }; // to assert on let updUrl, updCallback, updOptions, updBody; let trUrl, trCallback, trOptions, trBody; // Mocks mock('sendHttpRequest', function () { // mock response const respStatusCode = 200; const respHeaders = { foo: 'bar' }; const respBody = 'ok'; if (arguments[0] === updateURL) { updUrl = arguments[0]; updCallback = arguments[1]; updOptions = arguments[2]; updBody = arguments[3]; // and call the update callback with mock response updCallback(respStatusCode, respHeaders, respBody); } else { trUrl = arguments[0]; trCallback = arguments[1]; trOptions = arguments[2]; trBody = arguments[3]; // and call the track callback with mock response trCallback(respStatusCode, respHeaders, respBody); } }); mock('getContainerVersion', function () { let containerVersion = { // debug container debugMode: true, previewMode: true, }; return containerVersion; }); mock('getAllEventData', function () { return testEvent; }); mock('getEventData', function (x) { return getFromPath(x, testEvent); }); // Call runCode to run the template's code runCode(mockData); // Assert // In this test the input is an identity event // so we expect sendHttpRequest to have been called twice. assertApi('sendHttpRequest').wasCalled(); assertApi('sendHttpRequest').wasCalledWith( updUrl, updCallback, updOptions, updBody ); assertApi('sendHttpRequest').wasCalledWith( trUrl, trCallback, trOptions, trBody ); assertThat(updUrl).isStrictlyEqualTo(updateURL); assertThat(trUrl).isStrictlyEqualTo(trackURL); assertThat(updOptions.method).isStrictlyEqualTo('POST'); assertThat(updOptions.timeout).isStrictlyEqualTo(5000); assertThat(updOptions.headers['Content-Type']).isStrictlyEqualTo( 'application/json' ); assertThat(trOptions.method).isStrictlyEqualTo('POST'); assertThat(trOptions.timeout).isStrictlyEqualTo(5000); assertThat(trOptions.headers['Content-Type']).isStrictlyEqualTo( 'application/json' ); const updateBody = jsonApi.parse(updBody); assertThat(updateBody).isEqualTo(expectedIterableUpdateBody); const trackBody = jsonApi.parse(trBody); assertThat(trackBody).isEqualTo(expectedIterableTrackBody); // assert 'no' does not log in debug assertApi('logToConsole').wasNotCalled(); - name: Test Snowplow settings - include self-describing code: | const mockData = { apiKey: 'test', useClientIdFallback: true, useCommonEmail: true, useCommonUserId: true, useDefaultIdentify: true, includeSelfDescribingEvent: true, extractFromArray: false, includeEntities: 'none', includeCommonEventProperties: true, includeCommonUserProperties: true, mergeNestedUserData: false, logType: 'debug', }; const testEvent = mockEventObjectSelfDesc; const expectedIterableBody = { email: 'foo@test.io', userId: 'tester', eventName: 'media_player_event', id: 'c2084e30-5e4f-4d9c-86b2-e0bc3781509a', dataFields: { page_location: 'http://localhost:8000/', page_encoding: 'windows-1252', screen_resolution: '1920x1080', viewport_size: '1044x975', self_describing_event_com_snowplowanalytics_snowplow_media_player_event_1: { type: 'play', }, }, }; // to assert on let argUrl, argCallback, argOptions, argBody; // Mocks mock('sendHttpRequest', function () { // mock response const respStatusCode = 200; const respHeaders = { foo: 'bar' }; const respBody = 'ok'; argUrl = arguments[0]; argCallback = arguments[1]; argOptions = arguments[2]; argBody = arguments[3]; // and call the callback with mock response argCallback(respStatusCode, respHeaders, respBody); }); mock('getContainerVersion', function () { let containerVersion = { // prod container debugMode: false, previewMode: false, }; return containerVersion; }); mock('getAllEventData', function () { return testEvent; }); mock('getEventData', function (x) { return getFromPath(x, testEvent); }); // Call runCode to run the template's code runCode(mockData); // Assert assertApi('sendHttpRequest').wasCalled(); assertThat(argUrl).isStrictlyEqualTo(trackURL); assertThat(argOptions.method).isStrictlyEqualTo('POST'); assertThat(argOptions.timeout).isStrictlyEqualTo(5000); assertThat(argOptions.headers['Content-Type']).isStrictlyEqualTo( 'application/json' ); const body = jsonApi.parse(argBody); assertThat(body).isEqualTo(expectedIterableBody); // assert 'debug' does not log in prod assertApi('logToConsole').wasNotCalled(); - name: Test entity rules - include all - edit code: | const mockData = { apiKey: 'test', useClientIdFallback: true, useCommonEmail: true, useCommonUserId: true, useDefaultIdentify: false, identityEventsByName: ['media_player_event'], // for testing purposes includeSelfDescribingEvent: false, extractFromArray: true, includeEntities: 'all', entityMappingRules: [ { key: 'x-sp-contexts_com_snowplowanalytics_snowplow_mobile_context_1', mappedKey: 'mobile_context', propertiesObjectToPopulate: 'user_properties', version: 'control', }, { key: 'iglu:com.youtube/youtube/jsonschema/1-0-0', mappedKey: 'youtube', propertiesObjectToPopulate: 'event_properties', version: 'control', }, { key: 'contexts_com_snowplowanalytics_snowplow_media_player_1', mappedKey: 'media_player', propertiesObjectToPopulate: 'event_properties', version: 'control', }, { key: 'contexts_com_google_tag-manager_server-side_user_data_1', mappedKey: 'user_data_by_rule', propertiesObjectToPopulate: 'user_properties', version: 'control', }, ], includeCommonEventProperties: true, includeCommonUserProperties: true, mergeNestedUserData: false, logType: 'no', }; const testEvent = mockEventObjectSelfDesc; const expectedIterableUpdateBody = { email: 'foo@test.io', userId: 'tester', preferUserId: false, mergeNestedObjects: false, dataFields: { user_data_by_rule: { email_address: 'foo@test.io' }, mobile_context: { osType: 'myOsType', osVersion: 'myOsVersion', deviceManufacturer: 'myDevMan', deviceModel: 'myDevModel', }, }, }; const expectedIterableTrackBody = { email: 'foo@test.io', userId: 'tester', eventName: 'media_player_event', id: 'c2084e30-5e4f-4d9c-86b2-e0bc3781509a', dataFields: { page_location: 'http://localhost:8000/', page_encoding: 'windows-1252', screen_resolution: '1920x1080', viewport_size: '1044x975', youtube: { autoPlay: false, avaliablePlaybackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], buffering: false, controls: true, cued: false, loaded: 3, playbackQuality: 'medium', playerId: 'youtube-song', unstarted: false, url: 'https://www.youtube.com/watch?v=foobarbaz', avaliableQualityLevels: [ 'hd1080', 'hd720', 'large', 'medium', 'small', 'tiny', 'auto', ], }, media_player: { currentTime: 0.015303093460083008, duration: 190.301, ended: false, loop: false, muted: false, paused: false, playbackRate: 1, volume: 100, }, contexts_com_snowplowanalytics_snowplow_web_page_1: { id: '68027aa2-34b1-4018-95e3-7176c62dbc84', }, contexts_com_snowplowanalytics_snowplow_client_session_1: { userId: 'fd0e5288-e89b-45df-aad5-6d0c6eda6198', sessionId: '1ab28b79-bfdd-4855-9bf1-5199ce15beac', eventIndex: 24, sessionIndex: 1, previousSessionId: null, storageMechanism: 'COOKIE_1', firstEventId: '40fbdb30-1b99-42a3-99f7-850dacf5be43', firstEventTimestamp: '2022-07-23T09:08:04.451Z', }, }, }; // to assert on let updUrl, updCallback, updOptions, updBody; let trUrl, trCallback, trOptions, trBody; // Mocks mock('sendHttpRequest', function () { // mock response const respStatusCode = 200; const respHeaders = { foo: 'bar' }; const respBody = 'ok'; if (arguments[0] === updateURL) { updUrl = arguments[0]; updCallback = arguments[1]; updOptions = arguments[2]; updBody = arguments[3]; // and call the update callback with mock response updCallback(respStatusCode, respHeaders, respBody); } else { trUrl = arguments[0]; trCallback = arguments[1]; trOptions = arguments[2]; trBody = arguments[3]; // and call the track callback with mock response trCallback(respStatusCode, respHeaders, respBody); } }); mock('getAllEventData', function () { return testEvent; }); mock('getEventData', function (x) { return getFromPath(x, testEvent); }); // Call runCode to run the template's code runCode(mockData); // Assert // In this test the input is an identity event // so we expect sendHttpRequest to have been called twice. assertApi('sendHttpRequest').wasCalled(); assertApi('sendHttpRequest').wasCalledWith( updUrl, updCallback, updOptions, updBody ); assertApi('sendHttpRequest').wasCalledWith( trUrl, trCallback, trOptions, trBody ); const updateBody = jsonApi.parse(updBody); assertThat(updateBody).isEqualTo(expectedIterableUpdateBody); const trackBody = jsonApi.parse(trBody); assertThat(trackBody).isEqualTo(expectedIterableTrackBody); - name: Test entity rules - include none - version control code: | const testEvent = jsonApi.parse(jsonApi.stringify(mockEventObjectSelfDesc)); testEvent['x-sp-contexts_com_google_tag-manager_server-side_user_data_test_1'] = [{ email_address: 'fail@test.io' }]; testEvent['x-sp-contexts_com_youtube_youtube_test_1'] = [ { email_address: 'fail@test.io' }, ]; const mockData = { apiKey: 'test', useClientIdFallback: true, useCommonEmail: true, useCommonUserId: true, useDefaultIdentify: false, identityEventsByName: ['media_player_event'], // for testing purposes includeSelfDescribingEvent: false, extractFromArray: true, includeEntities: 'none', entityMappingRules: [ { key: 'iglu:com.youtube/youtube/jsonschema/1-5-0', mappedKey: 'youtube', propertiesObjectToPopulate: 'event_properties', version: 'free', }, { key: 'contexts_com_snowplowanalytics_snowplow_media_player_1', mappedKey: 'media_player', propertiesObjectToPopulate: 'event_properties', version: 'control', }, { key: 'contexts_com_google_tag-manager_server-side_user_data_5', mappedKey: 'user_data', propertiesObjectToPopulate: 'user_properties', version: 'free', }, ], includeCommonEventProperties: true, includeCommonUserProperties: true, mergeNestedUserData: false, logType: 'no', }; const expectedIterableUpdateBody = { email: 'foo@test.io', userId: 'tester', preferUserId: false, mergeNestedObjects: false, dataFields: { user_data: { email_address: 'foo@test.io' } }, }; const expectedIterableTrackBody = { email: 'foo@test.io', userId: 'tester', eventName: 'media_player_event', id: 'c2084e30-5e4f-4d9c-86b2-e0bc3781509a', dataFields: { page_location: 'http://localhost:8000/', page_encoding: 'windows-1252', screen_resolution: '1920x1080', viewport_size: '1044x975', youtube: { autoPlay: false, avaliablePlaybackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], buffering: false, controls: true, cued: false, loaded: 3, playbackQuality: 'medium', playerId: 'youtube-song', unstarted: false, url: 'https://www.youtube.com/watch?v=foobarbaz', avaliableQualityLevels: [ 'hd1080', 'hd720', 'large', 'medium', 'small', 'tiny', 'auto', ], }, media_player: { currentTime: 0.015303093460083008, duration: 190.301, ended: false, loop: false, muted: false, paused: false, playbackRate: 1, volume: 100, }, }, }; // to assert on let updUrl, updCallback, updOptions, updBody; let trUrl, trCallback, trOptions, trBody; // Mocks mock('sendHttpRequest', function () { // mock response const respStatusCode = 200; const respHeaders = { foo: 'bar' }; const respBody = 'ok'; if (arguments[0] === updateURL) { updUrl = arguments[0]; updCallback = arguments[1]; updOptions = arguments[2]; updBody = arguments[3]; // and call the update callback with mock response updCallback(respStatusCode, respHeaders, respBody); } else { trUrl = arguments[0]; trCallback = arguments[1]; trOptions = arguments[2]; trBody = arguments[3]; // and call the track callback with mock response trCallback(respStatusCode, respHeaders, respBody); } }); mock('getAllEventData', function () { return testEvent; }); mock('getEventData', function (x) { return getFromPath(x, testEvent); }); // Call runCode to run the template's code runCode(mockData); // Assert // In this test the input is an identity event // so we expect sendHttpRequest to have been called twice. assertApi('sendHttpRequest').wasCalled(); assertApi('sendHttpRequest').wasCalledWith( updUrl, updCallback, updOptions, updBody ); assertApi('sendHttpRequest').wasCalledWith( trUrl, trCallback, trOptions, trBody ); const updateBody = jsonApi.parse(updBody); assertThat(updateBody).isEqualTo(expectedIterableUpdateBody); const trackBody = jsonApi.parse(trBody); assertThat(trackBody).isEqualTo(expectedIterableTrackBody); - name: Test entity rules - exclude - version control code: | const testEvent = jsonApi.parse(jsonApi.stringify(mockEventObjectSelfDesc)); testEvent['x-sp-contexts_com_google_tag-manager_server-side_user_data_test_1'] = [{ email_address: 'fail@test.io' }]; testEvent['x-sp-contexts_com_youtube_youtube_test_1'] = [ { email_address: 'fail@test.io' }, ]; const mockData = { apiKey: 'test', useClientIdFallback: true, useCommonEmail: true, useCommonUserId: true, useDefaultIdentify: false, identityEventsByName: ['media_player_event'], // for testing purposes includeSelfDescribingEvent: false, extractFromArray: true, includeEntities: 'all', entityMappingRules: [ { key: 'iglu:com.youtube/youtube/jsonschema/1-0-0', mappedKey: 'youtube', propertiesObjectToPopulate: 'event_properties', version: 'control', }, { key: 'contexts_com_snowplowanalytics_snowplow_media_player_1', mappedKey: 'media_player', propertiesObjectToPopulate: 'event_properties', version: 'control', }, { key: 'contexts_com_google_tag-manager_server-side_user_data_1', mappedKey: 'user_data', propertiesObjectToPopulate: 'user_properties', version: 'control', }, ], entityExclusionRules: [ { key: 'contexts_com_snowplowanalytics_snowplow_web_page_1', version: 'control', }, { key: 'iglu:com.snowplowanalytics.snowplow/client_session/jsonschema/1-0-2', version: 'control', }, { key: 'x-sp-contexts_com_snowplowanalytics_snowplow_mobile_context_1', version: 'control', }, { key: 'x-sp-contexts_com_youtube_youtube_test_1', version: 'control', }, { key: 'x-sp-contexts_com_google_tag-manager_server-side_user_data_test', version: 'free', }, ], includeCommonEventProperties: true, includeCommonUserProperties: true, mergeNestedUserData: false, logType: 'no', }; const expectedIterableUpdateBody = { email: 'foo@test.io', userId: 'tester', preferUserId: false, mergeNestedObjects: false, dataFields: { user_data: { email_address: 'foo@test.io' } }, }; const expectedIterableTrackBody = { email: 'foo@test.io', userId: 'tester', eventName: 'media_player_event', id: 'c2084e30-5e4f-4d9c-86b2-e0bc3781509a', dataFields: { page_location: 'http://localhost:8000/', page_encoding: 'windows-1252', screen_resolution: '1920x1080', viewport_size: '1044x975', youtube: { autoPlay: false, avaliablePlaybackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], buffering: false, controls: true, cued: false, loaded: 3, playbackQuality: 'medium', playerId: 'youtube-song', unstarted: false, url: 'https://www.youtube.com/watch?v=foobarbaz', avaliableQualityLevels: [ 'hd1080', 'hd720', 'large', 'medium', 'small', 'tiny', 'auto', ], }, media_player: { currentTime: 0.015303093460083008, duration: 190.301, ended: false, loop: false, muted: false, paused: false, playbackRate: 1, volume: 100, }, }, }; // to assert on let updUrl, updCallback, updOptions, updBody; let trUrl, trCallback, trOptions, trBody; // Mocks mock('sendHttpRequest', function () { // mock response const respStatusCode = 200; const respHeaders = { foo: 'bar' }; const respBody = 'ok'; if (arguments[0] === updateURL) { updUrl = arguments[0]; updCallback = arguments[1]; updOptions = arguments[2]; updBody = arguments[3]; // and call the update callback with mock response updCallback(respStatusCode, respHeaders, respBody); } else { trUrl = arguments[0]; trCallback = arguments[1]; trOptions = arguments[2]; trBody = arguments[3]; // and call the track callback with mock response trCallback(respStatusCode, respHeaders, respBody); } }); mock('getAllEventData', function () { return testEvent; }); mock('getEventData', function (x) { return getFromPath(x, testEvent); }); // Call runCode to run the template's code runCode(mockData); // Assert // In this test the input is an identity event // so we expect sendHttpRequest to have been called twice. assertApi('sendHttpRequest').wasCalled(); assertApi('sendHttpRequest').wasCalledWith( updUrl, updCallback, updOptions, updBody ); assertApi('sendHttpRequest').wasCalledWith( trUrl, trCallback, trOptions, trBody ); const updateBody = jsonApi.parse(updBody); assertThat(updateBody).isEqualTo(expectedIterableUpdateBody); const trackBody = jsonApi.parse(trBody); assertThat(trackBody).isEqualTo(expectedIterableTrackBody); - name: Test entity rules - versioning cases - 1 code: | const mockData = { apiKey: 'test', useClientIdFallback: true, useCommonEmail: true, useCommonUserId: true, useDefaultIdentify: false, identityEventsByName: ['media_player_event'], // for testing purposes includeSelfDescribingEvent: false, extractFromArray: true, includeEntities: 'all', entityMappingRules: [ { key: 'x-sp-contexts_com_snowplowanalytics_snowplow_client_session_1', mappedKey: 'client_session_context', propertiesObjectToPopulate: 'event_properties', version: 'control', // control include }, { key: 'iglu:com.youtube/youtube/jsonschema/1-0-0', mappedKey: 'youtube', propertiesObjectToPopulate: 'event_properties', version: 'control', // control include }, { key: 'contexts_com_snowplowanalytics_snowplow_media_player_1', mappedKey: 'media_player', propertiesObjectToPopulate: 'event_properties', version: 'free', // free include }, { key: 'contexts_com_google_tag-manager_server-side_user_data', mappedKey: 'user_data', propertiesObjectToPopulate: 'user_properties', version: 'free', // free include }, ], entityExclusionRules: [ { key: 'contexts_com_snowplowanalytics_snowplow_web_page_1', version: 'control', }, { key: 'x-sp-contexts_com_snowplowanalytics_snowplow_mobile_context_1', version: 'control', }, // below we exclude entities also included { key: 'iglu:com.snowplowanalytics.snowplow/client_session/jsonschema/1-0-2', version: 'control', // control exclude }, { key: 'contexts_com_youtube_youtube_1', version: 'free', // free exclude }, { key: 'contexts_com_snowplowanalytics_snowplow_media_player_1', version: 'control', // control exclude }, { key: 'contexts_com_google_tag-manager_server-side_user_data', version: 'free', // free exclude }, ], includeCommonEventProperties: true, includeCommonUserProperties: true, mergeNestedUserData: false, logType: 'no', }; const testEvent = mockEventObjectSelfDesc; const expectedIterableUpdateBody = { email: 'foo@test.io', userId: 'tester', preferUserId: false, mergeNestedObjects: false, dataFields: {}, }; const expectedIterableTrackBody = { email: 'foo@test.io', userId: 'tester', eventName: 'media_player_event', id: 'c2084e30-5e4f-4d9c-86b2-e0bc3781509a', dataFields: { page_location: 'http://localhost:8000/', page_encoding: 'windows-1252', screen_resolution: '1920x1080', viewport_size: '1044x975', }, }; // to assert on let updUrl, updCallback, updOptions, updBody; let trUrl, trCallback, trOptions, trBody; // Mocks mock('sendHttpRequest', function () { // mock response const respStatusCode = 200; const respHeaders = { foo: 'bar' }; const respBody = 'ok'; if (arguments[0] === updateURL) { updUrl = arguments[0]; updCallback = arguments[1]; updOptions = arguments[2]; updBody = arguments[3]; // and call the update callback with mock response updCallback(respStatusCode, respHeaders, respBody); } else { trUrl = arguments[0]; trCallback = arguments[1]; trOptions = arguments[2]; trBody = arguments[3]; // and call the track callback with mock response trCallback(respStatusCode, respHeaders, respBody); } }); mock('getAllEventData', function () { return testEvent; }); mock('getEventData', function (x) { return getFromPath(x, testEvent); }); // Call runCode to run the template's code runCode(mockData); // Assert // In this test the input is an identity event // so we expect sendHttpRequest to have been called twice. assertApi('sendHttpRequest').wasCalled(); assertApi('sendHttpRequest').wasCalledWith( updUrl, updCallback, updOptions, updBody ); assertApi('sendHttpRequest').wasCalledWith( trUrl, trCallback, trOptions, trBody ); const updateBody = jsonApi.parse(updBody); assertThat(updateBody).isEqualTo(expectedIterableUpdateBody); const trackBody = jsonApi.parse(trBody); assertThat(trackBody).isEqualTo(expectedIterableTrackBody); - name: Test entity rules - versioning cases - 2 code: | const mockData = { apiKey: 'test', useClientIdFallback: true, useCommonEmail: true, useCommonUserId: true, useDefaultIdentify: false, identityEventsByName: ['media_player_event'], // for testing purposes includeSelfDescribingEvent: false, extractFromArray: true, includeEntities: 'all', entityMappingRules: [ { key: 'x-sp-contexts_com_snowplowanalytics_snowplow_client_session_2', mappedKey: 'client_session_context', propertiesObjectToPopulate: 'event_properties', version: 'control', // control include }, { key: 'iglu:com.youtube/youtube/jsonschema/2-0-0', mappedKey: 'youtube', propertiesObjectToPopulate: 'event_properties', version: 'control', // control include }, { key: 'contexts_com_snowplowanalytics_snowplow_media_player_2', mappedKey: 'media_player', propertiesObjectToPopulate: 'event_properties', version: 'free', // free include }, { key: 'contexts_com_google_tag-manager_server-side_user_data_2', mappedKey: 'user_data', propertiesObjectToPopulate: 'user_properties', version: 'free', // free include }, ], entityExclusionRules: [ { key: 'contexts_com_snowplowanalytics_snowplow_web_page_1', version: 'control', }, { key: 'x-sp-contexts_com_snowplowanalytics_snowplow_mobile_context_1', version: 'control', }, // below we exclude entities also included { key: 'iglu:com.snowplowanalytics.snowplow/client_session/jsonschema/2-0-2', version: 'control', // control exclude }, { key: 'contexts_com_youtube_youtube_2', version: 'free', // free exclude }, { key: 'contexts_com_snowplowanalytics_snowplow_media_player_2', version: 'control', // control exclude }, { key: 'contexts_com_google_tag-manager_server-side_user_data_2', version: 'free', // free exclude }, ], includeCommonEventProperties: true, includeCommonUserProperties: true, mergeNestedUserData: false, logType: 'no', }; const testEvent = mockEventObjectSelfDesc; const expectedIterableUpdateBody = { email: 'foo@test.io', userId: 'tester', preferUserId: false, mergeNestedObjects: false, dataFields: {}, }; const expectedIterableTrackBody = { email: 'foo@test.io', userId: 'tester', eventName: 'media_player_event', id: 'c2084e30-5e4f-4d9c-86b2-e0bc3781509a', dataFields: { page_location: 'http://localhost:8000/', page_encoding: 'windows-1252', screen_resolution: '1920x1080', viewport_size: '1044x975', media_player: { currentTime: 0.015303093460083008, duration: 190.301, ended: false, loop: false, muted: false, paused: false, playbackRate: 1, volume: 100, }, contexts_com_snowplowanalytics_snowplow_client_session_1: { userId: 'fd0e5288-e89b-45df-aad5-6d0c6eda6198', sessionId: '1ab28b79-bfdd-4855-9bf1-5199ce15beac', eventIndex: 24, sessionIndex: 1, previousSessionId: null, storageMechanism: 'COOKIE_1', firstEventId: '40fbdb30-1b99-42a3-99f7-850dacf5be43', firstEventTimestamp: '2022-07-23T09:08:04.451Z', }, }, }; // to assert on let updUrl, updCallback, updOptions, updBody; let trUrl, trCallback, trOptions, trBody; // Mocks mock('sendHttpRequest', function () { // mock response const respStatusCode = 200; const respHeaders = { foo: 'bar' }; const respBody = 'ok'; if (arguments[0] === updateURL) { updUrl = arguments[0]; updCallback = arguments[1]; updOptions = arguments[2]; updBody = arguments[3]; // and call the update callback with mock response updCallback(respStatusCode, respHeaders, respBody); } else { trUrl = arguments[0]; trCallback = arguments[1]; trOptions = arguments[2]; trBody = arguments[3]; // and call the track callback with mock response trCallback(respStatusCode, respHeaders, respBody); } }); mock('getAllEventData', function () { return testEvent; }); mock('getEventData', function (x) { return getFromPath(x, testEvent); }); // Call runCode to run the template's code runCode(mockData); // Assert // In this test the input is an identity event // so we expect sendHttpRequest to have been called twice. assertApi('sendHttpRequest').wasCalled(); assertApi('sendHttpRequest').wasCalledWith( updUrl, updCallback, updOptions, updBody ); assertApi('sendHttpRequest').wasCalledWith( trUrl, trCallback, trOptions, trBody ); const updateBody = jsonApi.parse(updBody); assertThat(updateBody).isEqualTo(expectedIterableUpdateBody); const trackBody = jsonApi.parse(trBody); assertThat(trackBody).isEqualTo(expectedIterableTrackBody); - name: Test entity rules - versioning cases - 3 code: | const mockData = { apiKey: 'test', useClientIdFallback: true, useCommonEmail: true, useCommonUserId: true, useDefaultIdentify: false, identityEventsByName: ['media_player_event'], // for testing purposes includeSelfDescribingEvent: false, extractFromArray: true, includeEntities: 'all', entityMappingRules: [ { key: 'x-sp-contexts_com_snowplowanalytics_snowplow_client_session_2', mappedKey: 'client_session_context', propertiesObjectToPopulate: 'user_properties', version: 'control', // control include }, { key: 'iglu:com.youtube/youtube/jsonschema/2-0-0', mappedKey: 'youtube', propertiesObjectToPopulate: 'user_properties', version: 'control', // control include }, { key: 'contexts_com_snowplowanalytics_snowplow_media_player_2', mappedKey: 'media_player', propertiesObjectToPopulate: 'user_properties', version: 'free', // free include }, { key: 'contexts_com_google_tag-manager_server-side_user_data_2', mappedKey: 'user_data', propertiesObjectToPopulate: 'user_properties', version: 'free', // free include }, ], entityExclusionRules: [ { key: 'contexts_com_snowplowanalytics_snowplow_web_page_1', version: 'control', }, { key: 'x-sp-contexts_com_snowplowanalytics_snowplow_mobile_context_1', version: 'control', }, // below we exclude entities also included { key: 'iglu:com.snowplowanalytics.snowplow/client_session/jsonschema/2-0-2', version: 'control', // control exclude }, { key: 'contexts_com_youtube_youtube_2', version: 'free', // free exclude }, { key: 'contexts_com_snowplowanalytics_snowplow_media_player_2', version: 'control', // control exclude }, { key: 'contexts_com_google_tag-manager_server-side_user_data_2', version: 'free', // free exclude }, ], includeCommonEventProperties: true, includeCommonUserProperties: true, mergeNestedUserData: false, logType: 'no', }; const testEvent = mockEventObjectSelfDesc; const expectedIterableUpdateBody = { email: 'foo@test.io', userId: 'tester', preferUserId: false, mergeNestedObjects: false, dataFields: { media_player: { currentTime: 0.015303093460083008, duration: 190.301, ended: false, loop: false, muted: false, paused: false, playbackRate: 1, volume: 100, }, }, }; const expectedIterableTrackBody = { email: 'foo@test.io', userId: 'tester', eventName: 'media_player_event', id: 'c2084e30-5e4f-4d9c-86b2-e0bc3781509a', dataFields: { page_location: 'http://localhost:8000/', page_encoding: 'windows-1252', screen_resolution: '1920x1080', viewport_size: '1044x975', contexts_com_snowplowanalytics_snowplow_client_session_1: { userId: 'fd0e5288-e89b-45df-aad5-6d0c6eda6198', sessionId: '1ab28b79-bfdd-4855-9bf1-5199ce15beac', eventIndex: 24, sessionIndex: 1, previousSessionId: null, storageMechanism: 'COOKIE_1', firstEventId: '40fbdb30-1b99-42a3-99f7-850dacf5be43', firstEventTimestamp: '2022-07-23T09:08:04.451Z', }, }, }; // to assert on let updUrl, updCallback, updOptions, updBody; let trUrl, trCallback, trOptions, trBody; // Mocks mock('sendHttpRequest', function () { // mock response const respStatusCode = 200; const respHeaders = { foo: 'bar' }; const respBody = 'ok'; if (arguments[0] === updateURL) { updUrl = arguments[0]; updCallback = arguments[1]; updOptions = arguments[2]; updBody = arguments[3]; // and call the update callback with mock response updCallback(respStatusCode, respHeaders, respBody); } else { trUrl = arguments[0]; trCallback = arguments[1]; trOptions = arguments[2]; trBody = arguments[3]; // and call the track callback with mock response trCallback(respStatusCode, respHeaders, respBody); } }); mock('getAllEventData', function () { return testEvent; }); mock('getEventData', function (x) { return getFromPath(x, testEvent); }); // Call runCode to run the template's code runCode(mockData); // Assert // In this test the input is an identity event // so we expect sendHttpRequest to have been called twice. assertApi('sendHttpRequest').wasCalled(); assertApi('sendHttpRequest').wasCalledWith( updUrl, updCallback, updOptions, updBody ); assertApi('sendHttpRequest').wasCalledWith( trUrl, trCallback, trOptions, trBody ); const updateBody = jsonApi.parse(updBody); assertThat(updateBody).isEqualTo(expectedIterableUpdateBody); const trackBody = jsonApi.parse(trBody); assertThat(trackBody).isEqualTo(expectedIterableTrackBody); - name: Test additional settings code: | const mockData = { apiKey: 'test', useClientIdFallback: true, useCommonEmail: true, useCommonUserId: true, useDefaultIdentify: true, includeSelfDescribingEvent: false, extractFromArray: true, includeEntities: 'none', includeCommonEventProperties: false, eventMappingRules: [ { key: 'x-sp-self_describing_event_com_snowplowanalytics_snowplow_media_player_event_1.type', mappedKey: 'media_event_type', }, { key: 'x-sp-contexts_com_youtube_youtube_1.0.autoPlay', mappedKey: 'autoPlay_in_event', }, ], includeCommonUserProperties: false, userMappingRules: [ { key: 'x-sp-contexts_com_google_tag-manager_server-side_user_data_1.0.address.city', mappedKey: 'city', }, ], mergeNestedUserData: false, logType: 'always', }; const testEvent = mockEventObjectSelfDesc; const expectedIterableBody = { userId: 'tester', email: 'foo@test.io', eventName: 'media_player_event', id: 'c2084e30-5e4f-4d9c-86b2-e0bc3781509a', dataFields: { media_event_type: 'play', autoPlay_in_event: false, }, }; // to assert on let argUrl, argCallback, argOptions, argBody; // Mocks mock('sendHttpRequest', function () { // mock response const respStatusCode = 200; const respHeaders = { foo: 'bar' }; const respBody = 'ok'; argUrl = arguments[0]; argCallback = arguments[1]; argOptions = arguments[2]; argBody = arguments[3]; // and call the callback with mock response argCallback(respStatusCode, respHeaders, respBody); }); mock('getContainerVersion', function () { let containerVersion = { // prod container debugMode: false, previewMode: false, }; return containerVersion; }); mock('getAllEventData', function () { return testEvent; }); mock('getEventData', function (x) { return getFromPath(x, testEvent); }); // Call runCode to run the template's code runCode(mockData); // Assert assertApi('sendHttpRequest').wasCalled(); assertThat(argUrl).isStrictlyEqualTo(trackURL); assertThat(argOptions.method).isStrictlyEqualTo('POST'); assertThat(argOptions.timeout).isStrictlyEqualTo(5000); assertThat(argOptions.headers['Content-Type']).isStrictlyEqualTo( 'application/json' ); const body = jsonApi.parse(argBody); assertThat(body).isEqualTo(expectedIterableBody); // assert 'always' does log in prod assertApi('logToConsole').wasCalled(); - name: Test additional settings - identify event code: | const mockData = { apiKey: 'test', useClientIdFallback: true, useCommonEmail: true, useCommonUserId: true, useDefaultIdentify: true, includeSelfDescribingEvent: true, extractFromArray: true, includeEntities: 'all', includeCommonEventProperties: false, eventMappingRules: [ { key: 'x-sp-app_id', mappedKey: 'app', }, ], includeCommonUserProperties: false, userMappingRules: [ { key: 'x-sp-contexts_com_google_tag-manager_server-side_user_data_1.0.address.city', mappedKey: 'city', }, { key: 'x-sp-br_features_flash', mappedKey: 'flash_in_user', }, ], mergeNestedUserData: false, logType: 'always', }; // Identify event const testEvent = mockEventObjectIdentify; // identity event const expectedIterableUpdateBody = { email: 'foo@test.io', userId: 'testUser', preferUserId: false, mergeNestedObjects: false, dataFields: { city: 'San Francisco', flash_in_user: false, }, }; const expectedIterableTrackBody = { email: 'foo@test.io', userId: 'testUser', eventName: 'identify', id: 'fe0cc7d2-52f3-4ae6-a67b-58c5d4798420', dataFields: { app: 'testApp', self_describing_event_com_snowplowanalytics_snowplow_identify_1: { id: 'fooBar', email: 'foo@test.io', }, 'contexts_com_google_tag-manager_server-side_user_data_1': { email_address: 'foo@test.io', address: { city: 'San Francisco' }, }, contexts_com_snowplowanalytics_snowplow_web_page_1: { id: '9ac461c5-10e1-4d19-a4f5-fb29f52ce706', }, }, }; // to assert on let updUrl, updCallback, updOptions, updBody; let trUrl, trCallback, trOptions, trBody; // Mocks mock('sendHttpRequest', function () { // mock response const respStatusCode = 200; const respHeaders = { foo: 'bar' }; const respBody = 'ok'; if (arguments[0] === updateURL) { updUrl = arguments[0]; updCallback = arguments[1]; updOptions = arguments[2]; updBody = arguments[3]; // and call the update callback with mock response updCallback(respStatusCode, respHeaders, respBody); } else { trUrl = arguments[0]; trCallback = arguments[1]; trOptions = arguments[2]; trBody = arguments[3]; // and call the track callback with mock response trCallback(respStatusCode, respHeaders, respBody); } }); mock('getContainerVersion', function () { let containerVersion = { // debug container debugMode: true, previewMode: true, }; return containerVersion; }); mock('getAllEventData', function () { return testEvent; }); mock('getEventData', function (x) { return getFromPath(x, testEvent); }); // Call runCode to run the template's code runCode(mockData); // Assert // In this test the input is an identity event // so we expect sendHttpRequest to have been called twice. assertApi('sendHttpRequest').wasCalled(); assertApi('sendHttpRequest').wasCalledWith( updUrl, updCallback, updOptions, updBody ); assertApi('sendHttpRequest').wasCalledWith( trUrl, trCallback, trOptions, trBody ); assertThat(updUrl).isStrictlyEqualTo(updateURL); assertThat(trUrl).isStrictlyEqualTo(trackURL); assertThat(updOptions.method).isStrictlyEqualTo('POST'); assertThat(updOptions.timeout).isStrictlyEqualTo(5000); assertThat(updOptions.headers['Content-Type']).isStrictlyEqualTo( 'application/json' ); assertThat(trOptions.method).isStrictlyEqualTo('POST'); assertThat(trOptions.timeout).isStrictlyEqualTo(5000); assertThat(trOptions.headers['Content-Type']).isStrictlyEqualTo( 'application/json' ); const updateBody = jsonApi.parse(updBody); assertThat(updateBody).isEqualTo(expectedIterableUpdateBody); const trackBody = jsonApi.parse(trBody); assertThat(trackBody).isEqualTo(expectedIterableTrackBody); // assert 'always' does log in debug assertApi('logToConsole').wasCalled(); - name: Test logs - Request and Response code: | // For simplicity reusing the Identify event test const mockData = { apiKey: 'test', useClientIdFallback: true, useCommonEmail: true, useCommonUserId: true, useDefaultIdentify: true, includeSelfDescribingEvent: true, extractFromArray: true, includeEntities: 'all', includeCommonEventProperties: false, eventMappingRules: [ { key: 'x-sp-app_id', mappedKey: 'app', }, ], includeCommonUserProperties: false, userMappingRules: [ { key: 'x-sp-contexts_com_google_tag-manager_server-side_user_data_1.0.address.city', mappedKey: 'city', }, ], mergeNestedUserData: false, logType: 'debug', }; // Identify event const testEvent = mockEventObjectIdentify; // identity event const expectedIterableUpdateBody = { email: 'foo@test.io', userId: 'testUser', preferUserId: false, mergeNestedObjects: false, dataFields: { city: 'San Francisco', }, }; const expectedIterableTrackBody = { email: 'foo@test.io', userId: 'testUser', eventName: 'identify', id: 'fe0cc7d2-52f3-4ae6-a67b-58c5d4798420', dataFields: { app: 'testApp', self_describing_event_com_snowplowanalytics_snowplow_identify_1: { id: 'fooBar', email: 'foo@test.io', }, 'contexts_com_google_tag-manager_server-side_user_data_1': { email_address: 'foo@test.io', address: { city: 'San Francisco' }, }, contexts_com_snowplowanalytics_snowplow_web_page_1: { id: '9ac461c5-10e1-4d19-a4f5-fb29f52ce706', }, }, }; // to assert on let updUrl, updCallback, updOptions, updBody; let trUrl, trCallback, trOptions, trBody; // Mocks mock('sendHttpRequest', function () { // mock response const respStatusCode = 200; const respHeaders = { foo: 'bar' }; const respBody = 'ok'; if (arguments[0] === updateURL) { updUrl = arguments[0]; updCallback = arguments[1]; updOptions = arguments[2]; updBody = arguments[3]; // and call the update callback with mock response updCallback(respStatusCode, respHeaders, respBody); } else { trUrl = arguments[0]; trCallback = arguments[1]; trOptions = arguments[2]; trBody = arguments[3]; // and call the track callback with mock response trCallback(respStatusCode, respHeaders, respBody); } }); mock('getContainerVersion', function () { let containerVersion = { // debug container debugMode: true, previewMode: true, }; return containerVersion; }); mock('getAllEventData', function () { return testEvent; }); mock('getEventData', function (x) { return getFromPath(x, testEvent); }); // Call runCode to run the template's code runCode(mockData); // Assert // In this test the input is an identity event // so we expect sendHttpRequest to have been called twice. assertApi('sendHttpRequest').wasCalled(); assertApi('sendHttpRequest').wasCalledWith( updUrl, updCallback, updOptions, updBody ); assertApi('sendHttpRequest').wasCalledWith( trUrl, trCallback, trOptions, trBody ); const updateBody = jsonApi.parse(updBody); assertThat(updateBody).isEqualTo(expectedIterableUpdateBody); const trackBody = jsonApi.parse(trBody); assertThat(trackBody).isEqualTo(expectedIterableTrackBody); // Expected to log 4 times const expectedUpdateRequestLog = jsonApi.stringify({ Name: 'Iterable', Type: 'Request', TraceId: 'someTestTraceId', EventName: 'identify', RequestMethod: 'POST', RequestUrl: updateURL, RequestHeaders: { 'api-key': 'redacted', 'Content-Type': 'application/json', }, RequestBody: expectedIterableUpdateBody, }); const expectedUpdateResponseLog = jsonApi.stringify({ Name: 'Iterable', Type: 'Response', TraceId: 'someTestTraceId', EventName: 'identify', ResponseStatusCode: 200, ResponseHeaders: { foo: 'bar' }, ResponseBody: 'ok', }); const expectedTrackRequestLog = jsonApi.stringify({ Name: 'Iterable', Type: 'Request', TraceId: 'someTestTraceId', EventName: 'identify', RequestMethod: 'POST', RequestUrl: trackURL, RequestHeaders: { 'api-key': 'redacted', 'Content-Type': 'application/json', }, RequestBody: expectedIterableTrackBody, }); const expectedTrackResponseLog = jsonApi.stringify({ Name: 'Iterable', Type: 'Response', TraceId: 'someTestTraceId', EventName: 'identify', ResponseStatusCode: 200, ResponseHeaders: { foo: 'bar' }, ResponseBody: 'ok', }); assertApi('logToConsole').wasCalled(); assertApi('logToConsole').wasCalledWith(expectedUpdateRequestLog); assertApi('logToConsole').wasCalledWith(expectedUpdateResponseLog); assertApi('logToConsole').wasCalledWith(expectedTrackRequestLog); assertApi('logToConsole').wasCalledWith(expectedTrackResponseLog); setup: |- const jsonApi = require('JSON'); const getTypeOf = require('getType'); const logToConsole = require('logToConsole'); const updateURL = 'https://api.iterable.com/api/users/update'; const trackURL = 'https://api.iterable.com/api/events/track'; const mockEventObjectPageView = { event_name: 'page_view', client_id: 'd54a1904-7798-401a-be0b-1a83bea73634', user_id: 'snow123', language: 'en-GB', page_encoding: 'UTF-8', page_hostname: 'snowplow.io', page_location: 'https://snowplow.io/', page_path: '/', page_referrer: 'referer', page_title: 'Collect, manage and operationalize behavioral data at scale | Snowplow', screen_resolution: '1920x1080', viewport_size: '745x1302', user_agent: 'user-agent', origin: 'origin', host: 'host', 'x-sp-app_id': 'website', 'x-sp-platform': 'web', 'x-sp-dvce_created_tstamp': '1628586512246', 'x-sp-event_id': '8676de79-0eba-4435-ad95-8a41a8a0129c', 'x-sp-name_tracker': 'sp', 'x-sp-v_tracker': 'js-2.18.1', 'x-sp-domain_sessionid': 'e7580b71-227b-4868-9ea9-322a263ce885', 'x-sp-domain_sessionidx': 1, 'x-sp-br_cookies': '1', 'x-sp-br_colordepth': '24', 'x-sp-br_viewwidth': 745, 'x-sp-br_viewheight': 1302, 'x-sp-dvce_screenwidth': 1920, 'x-sp-dvce_screenheight': 1080, 'x-sp-doc_charset': 'UTF-8', 'x-sp-doc_width': 730, 'x-sp-doc_height': 12393, 'x-sp-dvce_sent_tstamp': '1628586512248', 'x-sp-contexts_com_snowplowanalytics_snowplow_web_page_1': [ { id: 'a86c42e5-b831-45c8-b706-e214c26b4b3d', }, ], 'x-sp-contexts_org_w3_performance_timing_1': [ { navigationStart: 1628586508610, unloadEventStart: 0, unloadEventEnd: 0, redirectStart: 0, redirectEnd: 0, fetchStart: 1628586508610, domainLookupStart: 1628586508637, domainLookupEnd: 1628586508691, connectStart: 1628586508691, connectEnd: 1628586508763, secureConnectionStart: 1628586508721, requestStart: 1628586508763, responseStart: 1628586508797, responseEnd: 1628586508821, domLoading: 1628586509076, domInteractive: 1628586509381, domContentLoadedEventStart: 1628586509408, domContentLoadedEventEnd: 1628586509417, domComplete: 1628586510332, loadEventStart: 1628586510332, loadEventEnd: 1628586510334, }, ], 'x-sp-contexts_com_google_tag-manager_server-side_user_data_1': [ { email_address: 'foo@example.com', phone_number: '+15551234567', address: { first_name: 'Jane', last_name: 'Doe', street: '123 Fake St', city: 'San Francisco', region: 'CA', postal_code: '94016', country: 'US', }, }, ], user_data: { email_address: 'foo@example.com', phone_number: '+15551234567', address: { first_name: 'Jane', last_name: 'Doe', street: '123 Fake St', city: 'San Francisco', region: 'CA', postal_code: '94016', country: 'US', }, }, ga_session_id: 'e7580b71-227b-4868-9ea9-322a263ce885', ga_session_number: '1', 'x-ga-mp2-seg': '1', 'x-ga-protocol_version': '2', 'x-ga-page_id': 'a86c42e5-b831-45c8-b706-e214c26b4b3d', ip_override: '1.2.3.4', }; const mockEventObjectSignUp = { event_name: 'sign_up', client_id: '443cffcd-5d44-4b64-9606-df47f4fa821e', language: 'en-US', page_encoding: 'UTF-8', page_hostname: 'localhost', page_location: 'http://localhost:8000/', page_path: '/', screen_resolution: '1920x1080', user_id: 'testUser', viewport_size: '779x975', user_agent: 'curl/7.81.0', host: 'host', 'x-sp-app_id': 'testApp', 'x-sp-platform': 'web', 'x-sp-dvce_created_tstamp': '1662589318008', 'x-sp-event_id': '6926e78c-36ea-4424-b9d0-a014dca402de', 'x-sp-name_tracker': 'testTracker', 'x-sp-v_tracker': 'js-3.5.0', 'x-sp-domain_sessionid': 'b54bcc80-96ac-4226-ab2b-602b37b739e2', 'x-sp-domain_sessionidx': 1, 'x-sp-br_cookies': '1', 'x-sp-br_colordepth': '24', 'x-sp-br_viewwidth': 779, 'x-sp-br_viewheight': 975, 'x-sp-dvce_screenwidth': 1920, 'x-sp-dvce_screenheight': 1080, 'x-sp-doc_charset': 'UTF-8', 'x-sp-doc_width': 779, 'x-sp-doc_height': 975, 'x-sp-dvce_sent_tstamp': '1662589318010', 'x-sp-self_describing_event_com_google_tag-manager_server-side_sign_up_1': { method: 'fooMethod', }, 'x-sp-contexts_com_google_tag-manager_server-side_user_data_1': [ { email_address: 'foo@bar.baz', phone_number: '+15551234567', address: { first_name: 'Jane', last_name: 'Doe', street: '123 Fake St', city: 'San Francisco', region: 'CA', postal_code: '94016', country: 'US', }, }, ], 'x-sp-contexts_com_snowplowanalytics_snowplow_web_page_1': [ { id: '083ecbbc-da82-45e4-b76f-0b3f1902a7da' }, ], user_data: { email_address: 'foo@bar.baz', phone_number: '+15551234567', address: { first_name: 'Jane', last_name: 'Doe', street: '123 Fake St', city: 'San Francisco', region: 'CA', postal_code: '94016', country: 'US', }, }, ga_session_id: 'b54bcc80-96ac-4226-ab2b-602b37b739e2', ga_session_number: '1', 'x-ga-mp2-seg': '1', 'x-ga-protocol_version': '2', 'x-ga-page_id': '083ecbbc-da82-45e4-b76f-0b3f1902a7da', }; const mockEventObjectIdentify = { event_name: 'identify', client_id: '443cffcd-5d44-4b64-9606-df47f4fa821e', language: 'en-US', page_encoding: 'UTF-8', page_hostname: 'localhost', page_location: 'http://localhost:8000/', page_path: '/', screen_resolution: '1920x1080', user_id: 'testUser', viewport_size: '779x975', user_agent: 'curl/7.81.0', host: 'host', 'x-sp-app_id': 'testApp', 'x-sp-platform': 'web', 'x-sp-dvce_created_tstamp': '1662594886025', 'x-sp-event_id': 'fe0cc7d2-52f3-4ae6-a67b-58c5d4798420', 'x-sp-name_tracker': 'testTracker', 'x-sp-v_tracker': 'js-3.5.0', 'x-sp-domain_sessionid': 'c43da118-e237-45e8-8fc3-27b377561882', 'x-sp-domain_sessionidx': 2, 'x-sp-br_cookies': '1', 'x-sp-br_colordepth': '24', 'x-sp-br_viewwidth': 779, 'x-sp-br_viewheight': 975, 'x-sp-dvce_screenwidth': 1920, 'x-sp-dvce_screenheight': 1080, 'x-sp-doc_charset': 'UTF-8', 'x-sp-doc_width': 779, 'x-sp-doc_height': 975, 'x-sp-br_features_flash': false, 'x-sp-dvce_sent_tstamp': '1662594886028', 'x-sp-self_describing_event_com_snowplowanalytics_snowplow_identify_1': { id: 'fooBar', email: 'foo@test.io', }, 'x-sp-contexts_com_google_tag-manager_server-side_user_data_1': [ { email_address: 'foo@test.io', address: { city: 'San Francisco' } }, ], 'x-sp-contexts_com_snowplowanalytics_snowplow_web_page_1': [ { id: '9ac461c5-10e1-4d19-a4f5-fb29f52ce706' }, ], user_data: { email_address: 'foo@test.io', address: { city: 'San Francisco' }, }, ga_session_id: 'c43da118-e237-45e8-8fc3-27b377561882', ga_session_number: '2', 'x-ga-mp2-seg': '1', 'x-ga-protocol_version': '2', 'x-ga-page_id': '9ac461c5-10e1-4d19-a4f5-fb29f52ce706', }; const mockEventObjectSelfDesc = { event_name: 'media_player_event', client_id: 'fd0e5288-e89b-45df-aad5-6d0c6eda6198', language: 'en-US', page_encoding: 'windows-1252', page_hostname: 'localhost', page_location: 'http://localhost:8000/', page_path: '/', screen_resolution: '1920x1080', user_id: 'tester', viewport_size: '1044x975', user_agent: 'curl/7.81.0', host: 'host', 'x-sp-app_id': 'media-test', 'x-sp-platform': 'web', 'x-sp-dvce_created_tstamp': '1658567928426', 'x-sp-event_id': 'c2084e30-5e4f-4d9c-86b2-e0bc3781509a', 'x-sp-name_tracker': 'spTest', 'x-sp-v_tracker': 'js-3.5.0', 'x-sp-domain_sessionid': '1ab28b79-bfdd-4855-9bf1-5199ce15beac', 'x-sp-domain_sessionidx': 1, 'x-sp-br_cookies': '1', 'x-sp-br_colordepth': '24', 'x-sp-br_viewwidth': 1044, 'x-sp-br_viewheight': 975, 'x-sp-dvce_screenwidth': 1920, 'x-sp-dvce_screenheight': 1080, 'x-sp-doc_charset': 'windows-1252', 'x-sp-doc_width': 1044, 'x-sp-doc_height': 975, 'x-sp-dvce_sent_tstamp': '1658567928427', 'x-sp-self_describing_event_com_snowplowanalytics_snowplow_media_player_event_1': { type: 'play' }, 'x-sp-contexts_com_snowplowanalytics_snowplow_mobile_context_1': [ { osType: 'myOsType', osVersion: 'myOsVersion', deviceManufacturer: 'myDevMan', deviceModel: 'myDevModel', }, ], 'x-sp-contexts_com_youtube_youtube_1': [ { autoPlay: false, avaliablePlaybackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], buffering: false, controls: true, cued: false, loaded: 3, playbackQuality: 'medium', playerId: 'youtube-song', unstarted: false, url: 'https://www.youtube.com/watch?v=foobarbaz', avaliableQualityLevels: [ 'hd1080', 'hd720', 'large', 'medium', 'small', 'tiny', 'auto', ], }, ], 'x-sp-contexts_com_snowplowanalytics_snowplow_media_player_1': [ { currentTime: 0.015303093460083008, duration: 190.301, ended: false, loop: false, muted: false, paused: false, playbackRate: 1, volume: 100, }, ], 'x-sp-contexts_com_snowplowanalytics_snowplow_web_page_1': [ { id: '68027aa2-34b1-4018-95e3-7176c62dbc84' }, ], 'x-sp-contexts_com_google_tag-manager_server-side_user_data_1': [ { email_address: 'foo@test.io' }, ], 'x-sp-contexts_com_snowplowanalytics_snowplow_client_session_1': [ { userId: 'fd0e5288-e89b-45df-aad5-6d0c6eda6198', sessionId: '1ab28b79-bfdd-4855-9bf1-5199ce15beac', eventIndex: 24, sessionIndex: 1, previousSessionId: null, storageMechanism: 'COOKIE_1', firstEventId: '40fbdb30-1b99-42a3-99f7-850dacf5be43', firstEventTimestamp: '2022-07-23T09:08:04.451Z', }, ], 'x-sp-contexts': [ { schema: 'iglu:com.youtube/youtube/jsonschema/1-0-0', data: { autoPlay: false, avaliablePlaybackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], buffering: false, controls: true, cued: false, loaded: 3, playbackQuality: 'medium', playerId: 'youtube-song', unstarted: false, url: 'https://www.youtube.com/watch?v=foobarbaz', avaliableQualityLevels: [ 'hd1080', 'hd720', 'large', 'medium', 'small', 'tiny', 'auto', ], }, }, { schema: 'iglu:com.snowplowanalytics.snowplow/media_player/jsonschema/1-0-0', data: { currentTime: 0.015303093460083008, duration: 190.301, ended: false, loop: false, muted: false, paused: false, playbackRate: 1, volume: 100, }, }, { schema: 'iglu:com.snowplowanalytics.snowplow/web_page/jsonschema/1-0-0', data: { id: '68027aa2-34b1-4018-95e3-7176c62dbc84' }, }, { schema: 'iglu:com.google.tag-manager.server-side/user_data/jsonschema/1-0-0', data: { email_address: 'foo@test.io' }, }, { schema: 'iglu:com.snowplowanalytics.snowplow/client_session/jsonschema/1-0-2', data: { userId: 'fd0e5288-e89b-45df-aad5-6d0c6eda6198', sessionId: '1ab28b79-bfdd-4855-9bf1-5199ce15beac', eventIndex: 24, sessionIndex: 1, previousSessionId: null, storageMechanism: 'COOKIE_1', firstEventId: '40fbdb30-1b99-42a3-99f7-850dacf5be43', firstEventTimestamp: '2022-07-23T09:08:04.451Z', }, }, ], user_data: { email_address: 'foo@test.io' }, ga_session_id: '1ab28b79-bfdd-4855-9bf1-5199ce15beac', ga_session_number: '1', 'x-ga-mp2-seg': '1', 'x-ga-protocol_version': '2', 'x-ga-page_id': '68027aa2-34b1-4018-95e3-7176c62dbc84', }; // Helpers for mocking const getFromPath = (path, obj) => { if (getTypeOf(path) === 'string' && getTypeOf(obj) === 'object') { const splitPath = path.split('.').filter((prop) => !!prop); return splitPath.reduce((acc, curr) => acc && acc[curr], obj); } return undefined; }; mock('getRequestHeader', function (key) { // only interested for trace-id const headers = { 'trace-id': 'someTestTraceId', }; // naive return headers[key]; }); mock('getTimestampMillis', function () { return 1662403196056; // '2022-09-05T18:39:56.056Z' }); const testTime = '2022-09-05T18:39:56.056Z'; ___NOTES___ Created on 11/3/2021, 11:12:42 AM