diff --git a/lib/actions/customerio/customerio.d.ts b/lib/actions/customerio/customerio.d.ts new file mode 100644 index 000000000..97c75074f --- /dev/null +++ b/lib/actions/customerio/customerio.d.ts @@ -0,0 +1,52 @@ +import { TrackClient } from "customerio-node"; +import * as Hub from "../../hub"; +interface CustomerIoFields { + idFieldNames: string[]; + idField?: Hub.Field; + userIdField?: Hub.Field; + emailField?: Hub.Field; +} +export declare enum CustomerIoTags { + UserId = "user_id", + Email = "email" +} +export declare enum CustomerIoCalls { + Identify = "identify", + Track = "track" +} +export declare class CustomerIoAction extends Hub.Action { + allowedTags: CustomerIoTags[]; + name: string; + label: string; + iconName: string; + description: string; + params: { + description: string; + label: string; + name: string; + required: boolean; + sensitive: boolean; + }[]; + minimumSupportedLookerVersion: string; + supportedActionTypes: Hub.ActionType[]; + usesStreaming: boolean; + extendedAction: boolean; + supportedFormattings: Hub.ActionFormatting[]; + supportedVisualizationFormattings: Hub.ActionVisualizationFormatting[]; + requiredFields: { + any_tag: CustomerIoTags[]; + }[]; + executeInOwnProcess: boolean; + supportedFormats: (request: Hub.ActionRequest) => Hub.ActionFormat[]; + form(): Promise; + execute(request: Hub.ActionRequest): Promise; + protected executeCustomerIo(request: Hub.ActionRequest, customerIoCall: CustomerIoCalls): Promise; + protected unassignedCustomerIoFieldsCheck(customerIoFields: CustomerIoFields | undefined): void; + protected taggedFields(fields: Hub.Field[], tags: string[]): Hub.Field[]; + protected taggedField(fields: any[], tags: string[]): Hub.Field | undefined; + protected customerIoFields(fields: Hub.Field[]): CustomerIoFields; + protected filterJsonCustomerIo(jsonRow: any, customerIoFields: CustomerIoFields, fieldName: string): any; + protected prepareCustomerIoTraitsFromRow(row: Hub.JsonDetail.Row, fields: Hub.Field[], customerIoFields: CustomerIoFields, hiddenFields: string[], event: any, context: any, lookerAttributePrefix: string): any; + protected customerIoClientFromRequest(request: Hub.ActionRequest): TrackClient; +} +export {}; diff --git a/lib/actions/customerio/customerio.js b/lib/actions/customerio/customerio.js new file mode 100644 index 000000000..f52deb085 --- /dev/null +++ b/lib/actions/customerio/customerio.js @@ -0,0 +1,365 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CustomerIoAction = exports.CustomerIoCalls = exports.CustomerIoTags = void 0; +const customerio_node_1 = require("customerio-node"); +const https = require("https"); +const semver = require("semver"); +const util = require("util"); +const winston = require("winston"); +const Hub = require("../../hub"); +const customerio_error_1 = require("./customerio_error"); +const CUSTOMER_IO_UPDATE_DEFAULT_RATE_PER_SECOND_LIMIT = 500; +const CUSTOMER_IO_UPDATE_DEFAULT_REQUEST_TIMEOUT = 10000; +const logger = new (winston.Logger)({ + transports: [ + new (winston.transports.Console)({ timestamp: true }), + ], +}); +var CustomerIoTags; +(function (CustomerIoTags) { + CustomerIoTags["UserId"] = "user_id"; + CustomerIoTags["Email"] = "email"; +})(CustomerIoTags = exports.CustomerIoTags || (exports.CustomerIoTags = {})); +var CustomerIoCalls; +(function (CustomerIoCalls) { + CustomerIoCalls["Identify"] = "identify"; + CustomerIoCalls["Track"] = "track"; +})(CustomerIoCalls = exports.CustomerIoCalls || (exports.CustomerIoCalls = {})); +class CustomerIoAction extends Hub.Action { + constructor() { + super(...arguments); + this.allowedTags = [CustomerIoTags.Email, CustomerIoTags.UserId]; + this.name = "customerio_identify"; + this.label = "Customer.io Identify"; + this.iconName = "customerio/customerio.png"; + this.description = "Add traits via identify to your customer.io users."; + this.params = [ + { + description: "Site id for customer.io", + label: "Site ID", + name: "customer_io_site_id", + required: true, + sensitive: true, + }, + { + description: "Api key for customer.io", + label: "API Key", + name: "customer_io_api_key", + required: true, + sensitive: true, + }, + { + description: "Region for customer.io (could be RegionUS or RegionEU)", + label: "Region", + name: "customer_io_region", + required: true, + sensitive: false, + }, + { + description: `The maximum number of concurrent api calls should be less than: + ${CUSTOMER_IO_UPDATE_DEFAULT_RATE_PER_SECOND_LIMIT}`, + label: "Rate per second limit", + name: "customer_io_rate_per_second_limit", + required: false, + sensitive: false, + }, + { + description: `The request timeout for api calls in ms, default value is: + ${CUSTOMER_IO_UPDATE_DEFAULT_REQUEST_TIMEOUT}ms`, + label: "Request timeout", + name: "customer_io_request_timeout", + required: false, + sensitive: false, + }, + { + description: `Looker customer.io attribute prefix, could be something like looker_`, + label: "Attribute prefix", + name: "customer_io_looker_attribute_prefix", + required: false, + sensitive: false, + }, + ]; + this.minimumSupportedLookerVersion = "4.20.0"; + this.supportedActionTypes = [Hub.ActionType.Query]; + this.usesStreaming = true; + this.extendedAction = true; + this.supportedFormattings = [Hub.ActionFormatting.Unformatted]; + this.supportedVisualizationFormattings = [Hub.ActionVisualizationFormatting.Noapply]; + this.requiredFields = [{ any_tag: this.allowedTags }]; + this.executeInOwnProcess = true; + this.supportedFormats = (request) => { + if (request.lookerVersion && semver.gte(request.lookerVersion, "6.2.0")) { + return [Hub.ActionFormat.JsonDetailLiteStream]; + } + else { + return [Hub.ActionFormat.JsonDetail]; + } + }; + } + form() { + return __awaiter(this, void 0, void 0, function* () { + const form = new Hub.ActionForm(); + form.fields = [{ + description: "Override default api key", + label: "Override API Key", + name: "override_customer_io_api_key", + required: false, + }, { + description: "Override default site id", + label: "Override Site ID", + name: "override_customer_io_site_id", + required: false, + }]; + return form; + }); + } + execute(request) { + return __awaiter(this, void 0, void 0, function* () { + return this.executeCustomerIo(request, CustomerIoCalls.Identify); + }); + } + executeCustomerIo(request, customerIoCall) { + return __awaiter(this, void 0, void 0, function* () { + const customerIoClient = this.customerIoClientFromRequest(request); + let ratePerSecondLimit = CUSTOMER_IO_UPDATE_DEFAULT_RATE_PER_SECOND_LIMIT; + if (request.params.customer_io_rate_per_second_limit) { + ratePerSecondLimit = +request.params.customer_io_rate_per_second_limit; + } + let lookerAttributePrefix = ""; + if (request.params.customer_io_looker_attribute_prefix) { + lookerAttributePrefix = request.params.customer_io_looker_attribute_prefix; + } + let hiddenFields = []; + if (request.scheduledPlan && + request.scheduledPlan.query && + request.scheduledPlan.query.vis_config && + request.scheduledPlan.query.vis_config.hidden_fields) { + hiddenFields = request.scheduledPlan.query.vis_config.hidden_fields; + } + let customerIoFields; + let fieldset = []; + const errors = []; + const timestamp = Math.round(+new Date() / 1000); + const context = { + app: { + name: "looker/actions", + version: process.env.APP_VERSION ? process.env.APP_VERSION : "dev", + }, + }; + const event = request.formParams.event; + const batchUpdateObjects = []; + try { + yield request.streamJsonDetail({ + onFields: (fields) => { + fieldset = Hub.allFields(fields); + customerIoFields = this.customerIoFields(fieldset); + this.unassignedCustomerIoFieldsCheck(customerIoFields); + }, + onRanAt: (iso8601string) => { + if (iso8601string) { + winston.debug(`${timestamp}`); + } + }, + onRow: (row) => { + this.unassignedCustomerIoFieldsCheck(customerIoFields); + const payload = Object.assign({}, this.prepareCustomerIoTraitsFromRow(row, fieldset, customerIoFields, hiddenFields, event, { context, created_at: timestamp }, lookerAttributePrefix)); + try { + batchUpdateObjects.push({ + id: payload.id, + payload, + }); + } + catch (e) { + errors.push(e); + } + }, + }); + logger.debug(`Start ${batchUpdateObjects.length} for ${ratePerSecondLimit} ratePerSecondLimit`); + const erroredPromises = []; + if (customerIoCall in customerIoClient) { + const divider = ratePerSecondLimit; + let promiseArray = []; + for (let index = 0; index < batchUpdateObjects.length; index++) { + promiseArray.push(() => __awaiter(this, void 0, void 0, function* () { + return customerIoClient[customerIoCall](batchUpdateObjects[index].id, batchUpdateObjects[index].payload).then(() => { + winston.debug(`ok`); + }).catch((err) => __awaiter(this, void 0, void 0, function* () { + winston.debug(`retrying after first ${JSON.stringify(err)}`); + winston.debug(`trying to recover ${(index + 1)}`); + // await delayPromiseAll(600) + erroredPromises.push(batchUpdateObjects[index]); + customerIoClient[customerIoCall](batchUpdateObjects[index].id, batchUpdateObjects[index].payload).then(() => { + erroredPromises.splice(erroredPromises.findIndex((a) => a.id === batchUpdateObjects[index].id), 1); + winston.debug(`recovered ${(index + 1)}`); + }).catch((errRetry) => __awaiter(this, void 0, void 0, function* () { + winston.warn(errRetry.message); + })); + })); + })); + if (promiseArray.length === divider || index + 1 === batchUpdateObjects.length) { + yield Promise.all(promiseArray.map((promise) => promise())); + promiseArray = []; + winston.info(`${index + 1}/${batchUpdateObjects.length}`); + } + } + logger.debug(`Done ${batchUpdateObjects.length} for ${ratePerSecondLimit} ratePerSecondLimit`); + winston.warn(`errored ${erroredPromises.length}/${batchUpdateObjects.length}`); + } + else { + const error = `Unable to determine a the api request method for ${customerIoCall}`; + winston.error(error, request.webhookId); + errors.push(new customerio_error_1.CustomerIoActionError(`Error: ${error}`)); + } + } + catch (e) { + errors.push(e); + } + if (errors.length > 0) { + let msg = errors.map((e) => e.message ? e.message : e).join(", "); + if (msg.length === 0) { + msg = "An unknown error occurred while processing the customer.io action."; + winston.warn(`Can't format customer.io errors: ${util.inspect(errors)}`); + } + return new Hub.ActionResponse({ success: false, message: msg }); + } + else { + return new Hub.ActionResponse({ success: true }); + } + }); + } + unassignedCustomerIoFieldsCheck(customerIoFields) { + if (!(customerIoFields && customerIoFields.idFieldNames.length > 0)) { + throw new customerio_error_1.CustomerIoActionError(`Query requires a field tagged ${this.allowedTags.join(" or ")}.`); + } + } + taggedFields(fields, tags) { + return fields.filter((f) => f.tags && f.tags.length > 0 && f.tags.some((t) => tags.indexOf(t) !== -1)); + } + taggedField(fields, tags) { + return this.taggedFields(fields, tags)[0]; + } + customerIoFields(fields) { + const idFieldNames = this.taggedFields(fields, [ + CustomerIoTags.Email, + CustomerIoTags.UserId, + ]).map((f) => (f.name)); + return { + idFieldNames, + idField: this.taggedField(fields, [CustomerIoTags.UserId]), + userIdField: this.taggedField(fields, [CustomerIoTags.UserId]), + emailField: this.taggedField(fields, [CustomerIoTags.Email]), + }; + } + // Removes JsonDetail Cell metadata and only sends relevant nested data to Segment + // See JsonDetail.ts to see structure of a JsonDetail Row + filterJsonCustomerIo(jsonRow, customerIoFields, fieldName) { + const pivotValues = {}; + pivotValues[fieldName] = []; + const filterFunctionCustomerIo = (currentObject, name) => { + const returnVal = {}; + if (Object(currentObject) === currentObject) { + for (const key in currentObject) { + if (currentObject.hasOwnProperty(key)) { + if (key === "value") { + returnVal[name] = currentObject[key]; + return returnVal; + } + else if (customerIoFields.idFieldNames.indexOf(key) === -1) { + const res = filterFunctionCustomerIo(currentObject[key], key); + if (res !== {}) { + pivotValues[fieldName].push(res); + } + } + } + } + } + return returnVal; + }; + filterFunctionCustomerIo(jsonRow, fieldName); + return pivotValues; + } + prepareCustomerIoTraitsFromRow(row, fields, customerIoFields, hiddenFields, event, context, lookerAttributePrefix) { + const traits = {}; + for (const field of fields) { + if (customerIoFields.idFieldNames.indexOf(field.name) === -1) { + if (hiddenFields.indexOf(field.name) === -1) { + let values = {}; + if (!row.hasOwnProperty(field.name)) { + winston.error("Field name does not exist for customer.io action"); + throw new customerio_error_1.CustomerIoActionError(`Field id ${field.name} does not exist for JsonDetail.Row`); + } + if (row[field.name].value || row[field.name].value === 0) { + values[field.name] = row[field.name].value; + } + else { + values = this.filterJsonCustomerIo(row[field.name], customerIoFields, field.name); + } + for (const key in values) { + if (values.hasOwnProperty(key)) { + const customKey = key.indexOf(".") >= 0 ? key.split(".")[1] : key; + traits[lookerAttributePrefix + customKey] = values[key]; + } + } + } + } + if (customerIoFields.emailField && field.name === customerIoFields.emailField.name && row[field.name]) { + traits.email = row[field.name].value; + } + } + const id = customerIoFields.idField ? row[customerIoFields.idField.name].value : null; + const email = customerIoFields.emailField && customerIoFields.emailField.name in row + ? row[customerIoFields.emailField.name].value : null; + const segmentRow = { + id: id || email, + }; + context.context.app.looker_sent_at = +context.created_at; + delete context.created_at; + if (event) { + return Object.assign(Object.assign({ name: event }, { data: Object.assign(Object.assign({}, traits), context), email: traits.email }), segmentRow); + } + else { + return Object.assign(Object.assign(Object.assign(Object.assign({}, traits), context), segmentRow), { _update: true }); + } + } + customerIoClientFromRequest(request) { + let cioRegion = customerio_node_1.RegionUS; + switch (request.params.customer_io_region) { + case "RegionUS": + cioRegion = customerio_node_1.RegionUS; + break; + case "RegionEU": + cioRegion = customerio_node_1.RegionEU; + break; + default: + throw new customerio_error_1.CustomerIoActionError(`Customer.io requires a valig region (RegionUS or RegionEU)`); + } + let requestTimeout = CUSTOMER_IO_UPDATE_DEFAULT_REQUEST_TIMEOUT; + if (request.params.customer_io_request_timeout) { + requestTimeout = +request.params.customer_io_request_timeout; + } + let siteId = "" + request.params.customer_io_site_id; + if (request.formParams.customer_io_site_id && request.formParams.customer_io_site_id.length > 0) { + siteId = request.formParams.customer_io_site_id; + } + let apiKey = "" + request.params.customer_io_api_key; + if (request.formParams.customer_io_api_key && request.formParams.customer_io_api_key.length > 0) { + apiKey = request.formParams.customer_io_api_key; + } + const keepAliveAgent = new https.Agent({ keepAlive: true }); + return new customerio_node_1.TrackClient(siteId, apiKey, { + region: cioRegion, timeout: requestTimeout, + agent: keepAliveAgent, + }); + } +} +exports.CustomerIoAction = CustomerIoAction; +Hub.addAction(new CustomerIoAction()); diff --git a/lib/actions/customerio/customerio_error.d.ts b/lib/actions/customerio/customerio_error.d.ts new file mode 100644 index 000000000..dc46a992b --- /dev/null +++ b/lib/actions/customerio/customerio_error.d.ts @@ -0,0 +1,3 @@ +export declare class CustomerIoActionError extends Error { + constructor(message?: string); +} diff --git a/lib/actions/customerio/customerio_error.js b/lib/actions/customerio/customerio_error.js new file mode 100644 index 000000000..53db415dc --- /dev/null +++ b/lib/actions/customerio/customerio_error.js @@ -0,0 +1,10 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CustomerIoActionError = void 0; +class CustomerIoActionError extends Error { + constructor(message) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} +exports.CustomerIoActionError = CustomerIoActionError; diff --git a/lib/actions/customerio/customerio_track.d.ts b/lib/actions/customerio/customerio_track.d.ts new file mode 100644 index 000000000..9942fb7bf --- /dev/null +++ b/lib/actions/customerio/customerio_track.d.ts @@ -0,0 +1,11 @@ +import * as Hub from "../../hub"; +import { CustomerIoAction } from "./customerio"; +export declare class CustomerIoTrackAction extends CustomerIoAction { + name: string; + label: string; + iconName: string; + description: string; + minimumSupportedLookerVersion: string; + execute(request: Hub.ActionRequest): Promise; + form(): Promise; +} diff --git a/lib/actions/customerio/customerio_track.js b/lib/actions/customerio/customerio_track.js new file mode 100644 index 000000000..eae84718a --- /dev/null +++ b/lib/actions/customerio/customerio_track.js @@ -0,0 +1,57 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CustomerIoTrackAction = void 0; +const Hub = require("../../hub"); +const customerio_1 = require("./customerio"); +class CustomerIoTrackAction extends customerio_1.CustomerIoAction { + constructor() { + super(...arguments); + this.name = "customerio_track"; + this.label = "Customer.io Track"; + this.iconName = "customerio/customerio.png"; + this.description = "Add traits via track to your customer.io users."; + this.minimumSupportedLookerVersion = "5.5.0"; + } + execute(request) { + return __awaiter(this, void 0, void 0, function* () { + return this.executeCustomerIo(request, customerio_1.CustomerIoCalls.Track); + }); + } + form() { + return __awaiter(this, void 0, void 0, function* () { + const form = new Hub.ActionForm(); + form.fields = [ + { + name: "event", + label: "Event", + description: "The name of the event you’re tracking.", + type: "string", + required: true, + }, + { + description: "Override default api key", + label: "Override API Key", + name: "override_customer_io_api_key", + required: false, + }, { + description: "Override default site id", + label: "Override Site ID", + name: "override_customer_io_site_id", + required: false, + }, + ]; + return form; + }); + } +} +exports.CustomerIoTrackAction = CustomerIoTrackAction; +Hub.addAction(new CustomerIoTrackAction()); diff --git a/lib/actions/index.d.ts b/lib/actions/index.d.ts index d7506dad0..8bb96aee4 100644 --- a/lib/actions/index.d.ts +++ b/lib/actions/index.d.ts @@ -4,6 +4,8 @@ import "./amazon/amazon_s3"; import "./auger/auger_train"; import "./azure/azure_storage"; import "./braze/braze"; +import "./customerio/customerio"; +import "./customerio/customerio_track"; import "./datarobot/datarobot"; import "./digitalocean/digitalocean_droplet"; import "./digitalocean/digitalocean_object_storage"; diff --git a/lib/actions/index.js b/lib/actions/index.js index ed844c2d3..0b0f37de9 100644 --- a/lib/actions/index.js +++ b/lib/actions/index.js @@ -6,6 +6,8 @@ require("./amazon/amazon_s3"); require("./auger/auger_train"); require("./azure/azure_storage"); require("./braze/braze"); +require("./customerio/customerio"); +require("./customerio/customerio_track"); require("./datarobot/datarobot"); require("./digitalocean/digitalocean_droplet"); require("./digitalocean/digitalocean_object_storage"); diff --git a/package.json b/package.json index f3a924497..85253f646 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "async-mutex": "^0.1.4", "aws-sdk": "^2.732.0", "azure-storage": "^2.10.3", + "customerio-node": "^3.3.0", "base64-url": "^2.3.3", "blocked-at": "^1.2.0", "body-parser": "^1.19.0", diff --git a/src/actions/customerio/README.md b/src/actions/customerio/README.md new file mode 100644 index 000000000..cfb0abbe8 --- /dev/null +++ b/src/actions/customerio/README.md @@ -0,0 +1,4 @@ +# customer.io +## Add identifiers to your customer.io users. + +The customer.io action allows you to Identify users (tagged with either `email`, `user_id`) with additional fields via the customer.io API. diff --git a/src/actions/customerio/customerio.png b/src/actions/customerio/customerio.png new file mode 100644 index 000000000..00c50029e Binary files /dev/null and b/src/actions/customerio/customerio.png differ diff --git a/src/actions/customerio/customerio.ts b/src/actions/customerio/customerio.ts new file mode 100644 index 000000000..c5911e75f --- /dev/null +++ b/src/actions/customerio/customerio.ts @@ -0,0 +1,381 @@ +import {RegionEU, RegionUS, TrackClient} from "customerio-node" +import * as https from "https" +import * as semver from "semver" +import * as util from "util" +import * as winston from "winston" +import * as Hub from "../../hub" +import {CustomerIoActionError} from "./customerio_error" + +const CUSTOMER_IO_UPDATE_DEFAULT_RATE_PER_SECOND_LIMIT = 500 +const CUSTOMER_IO_UPDATE_DEFAULT_REQUEST_TIMEOUT = 10000 +const logger = new (winston.Logger)({ + transports: [ + new (winston.transports.Console)({timestamp: true}), + ], +}) + +interface CustomerIoFields { + idFieldNames: string[], + idField?: Hub.Field, + userIdField?: Hub.Field, + emailField?: Hub.Field, +} + +export enum CustomerIoTags { + UserId = "user_id", + Email = "email", +} + +export enum CustomerIoCalls { + Identify = "identify", + Track = "track", +} + +export class CustomerIoAction extends Hub.Action { + + allowedTags = [CustomerIoTags.Email, CustomerIoTags.UserId] + + name = "customerio_identify" + label = "Customer.io Identify" + iconName = "customerio/customerio.png" + description = "Add traits via identify to your customer.io users." + params = [ + { + description: "Site id for customer.io", + label: "Site ID", + name: "customer_io_site_id", + required: true, + sensitive: true, + }, + { + description: "Api key for customer.io", + label: "API Key", + name: "customer_io_api_key", + required: true, + sensitive: true, + }, + { + description: "Region for customer.io (could be RegionUS or RegionEU)", + label: "Region", + name: "customer_io_region", + required: true, + sensitive: false, + }, + { + description: `The maximum number of concurrent api calls should be less than: + ${CUSTOMER_IO_UPDATE_DEFAULT_RATE_PER_SECOND_LIMIT}`, + label: "Rate per second limit", + name: "customer_io_rate_per_second_limit", + required: false, + sensitive: false, + }, + { + description: `The request timeout for api calls in ms, default value is: + ${CUSTOMER_IO_UPDATE_DEFAULT_REQUEST_TIMEOUT}ms`, + label: "Request timeout", + name: "customer_io_request_timeout", + required: false, + sensitive: false, + }, + { + description: `Looker customer.io attribute prefix, could be something like looker_`, + label: "Attribute prefix", + name: "customer_io_looker_attribute_prefix", + required: false, + sensitive: false, + }, + ] + minimumSupportedLookerVersion = "4.20.0" + supportedActionTypes = [Hub.ActionType.Query] + usesStreaming = true + extendedAction = true + supportedFormattings = [Hub.ActionFormatting.Unformatted] + supportedVisualizationFormattings = [Hub.ActionVisualizationFormatting.Noapply] + requiredFields = [{any_tag: this.allowedTags}] + executeInOwnProcess = true + supportedFormats = (request: Hub.ActionRequest) => { + if (request.lookerVersion && semver.gte(request.lookerVersion, "6.2.0")) { + return [Hub.ActionFormat.JsonDetailLiteStream] + } else { + return [Hub.ActionFormat.JsonDetail] + } + } + + async form() { + const form = new Hub.ActionForm() + form.fields = [{ + description: "Override default api key", + label: "Override API Key", + name: "override_customer_io_api_key", + required: false, + }, { + description: "Override default site id", + label: "Override Site ID", + name: "override_customer_io_site_id", + required: false, + }] + return form + } + + async execute(request: Hub.ActionRequest) { + return this.executeCustomerIo(request, CustomerIoCalls.Identify) + } + + protected async executeCustomerIo(request: Hub.ActionRequest, customerIoCall: CustomerIoCalls) { + const customerIoClient = this.customerIoClientFromRequest(request) + let ratePerSecondLimit = CUSTOMER_IO_UPDATE_DEFAULT_RATE_PER_SECOND_LIMIT + if (request.params.customer_io_rate_per_second_limit) { + ratePerSecondLimit = +request.params.customer_io_rate_per_second_limit + } + let lookerAttributePrefix = "" + if (request.params.customer_io_looker_attribute_prefix) { + lookerAttributePrefix = request.params.customer_io_looker_attribute_prefix + } + let hiddenFields: string[] = [] + if (request.scheduledPlan && + request.scheduledPlan.query && + request.scheduledPlan.query.vis_config && + request.scheduledPlan.query.vis_config.hidden_fields) { + hiddenFields = request.scheduledPlan.query.vis_config.hidden_fields + } + + let customerIoFields: CustomerIoFields | undefined + let fieldset: Hub.Field[] = [] + const errors: Error[] = [] + + const timestamp = Math.round(+new Date() / 1000) + const context = { + app: { + name: "looker/actions", + version: process.env.APP_VERSION ? process.env.APP_VERSION : "dev", + }, + } + const event = request.formParams.event + const batchUpdateObjects: any = [] + try { + + await request.streamJsonDetail({ + onFields: (fields) => { + fieldset = Hub.allFields(fields) + customerIoFields = this.customerIoFields(fieldset) + this.unassignedCustomerIoFieldsCheck(customerIoFields) + }, + onRanAt: (iso8601string) => { + if (iso8601string) { + winston.debug(`${timestamp}`) + } + }, + onRow: (row) => { + this.unassignedCustomerIoFieldsCheck(customerIoFields) + const payload = { + ...this.prepareCustomerIoTraitsFromRow( + row, fieldset, customerIoFields!, hiddenFields, event, + {context, created_at: timestamp}, lookerAttributePrefix), + } + try { + batchUpdateObjects.push({ + id: payload.id, + payload, + }) + } catch (e) { + errors.push(e) + } + }, + }) + logger.debug(`Start ${batchUpdateObjects.length} for ${ratePerSecondLimit} ratePerSecondLimit`) + const erroredPromises: any = [] + if (customerIoCall in customerIoClient) { + const divider = ratePerSecondLimit + let promiseArray: any = [] + for (let index = 0; index < batchUpdateObjects.length; index++) { + promiseArray.push(async () => { + return customerIoClient[customerIoCall](batchUpdateObjects[index].id, + batchUpdateObjects[index].payload).then(() => { + winston.debug(`ok`) + }).catch(async (err: any) => { + winston.debug(`retrying after first ${JSON.stringify(err)}`) + winston.debug(`trying to recover ${(index + 1)}`) + // await delayPromiseAll(600) + erroredPromises.push(batchUpdateObjects[index]) + customerIoClient[customerIoCall](batchUpdateObjects[index].id, + batchUpdateObjects[index].payload).then(() => { + erroredPromises.splice( + erroredPromises.findIndex((a: any) => a.id === batchUpdateObjects[index].id), 1) + winston.debug(`recovered ${(index + 1)}`) + }).catch(async (errRetry: any) => { + winston.warn(errRetry.message) + }) + }) + }) + if (promiseArray.length === divider || index + 1 === batchUpdateObjects.length) { + await Promise.all(promiseArray.map((promise: any) => promise())) + promiseArray = [] + winston.info(`${index + 1}/${batchUpdateObjects.length}`) + } + } + logger.debug(`Done ${batchUpdateObjects.length} for ${ratePerSecondLimit} ratePerSecondLimit`) + winston.warn(`errored ${erroredPromises.length}/${batchUpdateObjects.length}`) + } else { + const error = `Unable to determine a the api request method for ${customerIoCall}` + winston.error(error, request.webhookId) + errors.push(new CustomerIoActionError(`Error: ${error}`)) + } + } catch (e) { + errors.push(e) + } + + if (errors.length > 0) { + let msg = errors.map((e) => e.message ? e.message : e).join(", ") + if (msg.length === 0) { + msg = "An unknown error occurred while processing the customer.io action." + winston.warn(`Can't format customer.io errors: ${util.inspect(errors)}`) + } + return new Hub.ActionResponse({success: false, message: msg}) + } else { + return new Hub.ActionResponse({success: true}) + } + } + + protected unassignedCustomerIoFieldsCheck(customerIoFields: CustomerIoFields | undefined) { + if (!(customerIoFields && customerIoFields.idFieldNames.length > 0)) { + throw new CustomerIoActionError(`Query requires a field tagged ${this.allowedTags.join(" or ")}.`) + } + } + + protected taggedFields(fields: Hub.Field[], tags: string[]) { + return fields.filter((f) => + f.tags && f.tags.length > 0 && f.tags.some((t: string) => tags.indexOf(t) !== -1), + ) + } + + protected taggedField(fields: any[], tags: string[]): Hub.Field | undefined { + return this.taggedFields(fields, tags)[0] + } + + protected customerIoFields(fields: Hub.Field[]): CustomerIoFields { + const idFieldNames = this.taggedFields(fields, [ + CustomerIoTags.Email, + CustomerIoTags.UserId, + ]).map((f: Hub.Field) => (f.name)) + + return { + idFieldNames, + idField: this.taggedField(fields, [CustomerIoTags.UserId]), + userIdField: this.taggedField(fields, [CustomerIoTags.UserId]), + emailField: this.taggedField(fields, [CustomerIoTags.Email]), + } + } + + // Removes JsonDetail Cell metadata and only sends relevant nested data to Segment + // See JsonDetail.ts to see structure of a JsonDetail Row + protected filterJsonCustomerIo(jsonRow: any, customerIoFields: CustomerIoFields, fieldName: string) { + const pivotValues: any = {} + pivotValues[fieldName] = [] + const filterFunctionCustomerIo = (currentObject: any, name: string) => { + const returnVal: any = {} + if (Object(currentObject) === currentObject) { + for (const key in currentObject) { + if (currentObject.hasOwnProperty(key)) { + if (key === "value") { + returnVal[name] = currentObject[key] + return returnVal + } else if (customerIoFields.idFieldNames.indexOf(key) === -1) { + const res = filterFunctionCustomerIo(currentObject[key], key) + if (res !== {}) { + pivotValues[fieldName].push(res) + } + } + } + } + } + return returnVal + } + filterFunctionCustomerIo(jsonRow, fieldName) + return pivotValues + } + + protected prepareCustomerIoTraitsFromRow( + row: Hub.JsonDetail.Row, + fields: Hub.Field[], + customerIoFields: CustomerIoFields, + hiddenFields: string[], + event: any, + context: any, + lookerAttributePrefix: string, + ) { + const traits: { [key: string]: string } = {} + for (const field of fields) { + if (customerIoFields.idFieldNames.indexOf(field.name) === -1) { + if (hiddenFields.indexOf(field.name) === -1) { + let values: any = {} + if (!row.hasOwnProperty(field.name)) { + winston.error("Field name does not exist for customer.io action") + throw new CustomerIoActionError(`Field id ${field.name} does not exist for JsonDetail.Row`) + } + if (row[field.name].value || row[field.name].value === 0) { + values[field.name] = row[field.name].value + } else { + values = this.filterJsonCustomerIo(row[field.name], customerIoFields, field.name) + } + for (const key in values) { + if (values.hasOwnProperty(key)) { + const customKey = key.indexOf(".") >= 0 ? key.split(".")[1] : key + traits[lookerAttributePrefix + customKey] = values[key] + } + } + } + } + if (customerIoFields.emailField && field.name === customerIoFields.emailField.name && row[field.name]) { + traits.email = row[field.name].value + } + } + const id: string | null = customerIoFields.idField ? row[customerIoFields.idField.name].value : null + const email: string | null = customerIoFields.emailField && customerIoFields.emailField.name in row + ? row[customerIoFields.emailField.name].value : null + + const segmentRow: any = { + id: id || email, + } + context.context.app.looker_sent_at = +context.created_at + delete context.created_at + if (event) { + return {...{name: event}, ...{data: {...traits, ...context}, email: traits.email}, ...segmentRow} + } else { + return {...traits, ...context, ...segmentRow, _update: true} + } + } + + protected customerIoClientFromRequest(request: Hub.ActionRequest) { + let cioRegion = RegionUS + switch (request.params.customer_io_region) { + case "RegionUS": + cioRegion = RegionUS + break + case "RegionEU": + cioRegion = RegionEU + break + default: + throw new CustomerIoActionError(`Customer.io requires a valig region (RegionUS or RegionEU)`) + } + let requestTimeout = CUSTOMER_IO_UPDATE_DEFAULT_REQUEST_TIMEOUT + if (request.params.customer_io_request_timeout) { + requestTimeout = +request.params.customer_io_request_timeout + } + let siteId: string = "" + request.params.customer_io_site_id + if (request.formParams.customer_io_site_id && request.formParams.customer_io_site_id.length > 0) { + siteId = request.formParams.customer_io_site_id + } + let apiKey: string = "" + request.params.customer_io_api_key + if (request.formParams.customer_io_api_key && request.formParams.customer_io_api_key.length > 0) { + apiKey = request.formParams.customer_io_api_key + } + const keepAliveAgent = new https.Agent({ keepAlive: true }) + return new TrackClient(siteId, apiKey, { + region: cioRegion, timeout: requestTimeout, + agent: keepAliveAgent, + }) + } + +} + +Hub.addAction(new CustomerIoAction()) diff --git a/src/actions/customerio/customerio_error.ts b/src/actions/customerio/customerio_error.ts new file mode 100644 index 000000000..6f44a8b92 --- /dev/null +++ b/src/actions/customerio/customerio_error.ts @@ -0,0 +1,6 @@ +export class CustomerIoActionError extends Error { + constructor(message?: string) { + super(message) + Object.setPrototypeOf(this, new.target.prototype) + } +} diff --git a/src/actions/customerio/customerio_track.ts b/src/actions/customerio/customerio_track.ts new file mode 100644 index 000000000..2d654cc2c --- /dev/null +++ b/src/actions/customerio/customerio_track.ts @@ -0,0 +1,43 @@ +import * as Hub from "../../hub" +import {CustomerIoAction, CustomerIoCalls} from "./customerio" + +export class CustomerIoTrackAction extends CustomerIoAction { + + name = "customerio_track" + label = "Customer.io Track" + iconName = "customerio/customerio.png" + description = "Add traits via track to your customer.io users." + minimumSupportedLookerVersion = "5.5.0" + + async execute(request: Hub.ActionRequest) { + return this.executeCustomerIo(request, CustomerIoCalls.Track) + } + + async form() { + const form = new Hub.ActionForm() + form.fields = [ + { + name: "event", + label: "Event", + description: "The name of the event you’re tracking.", + type: "string", + required: true, + }, + { + description: "Override default api key", + label: "Override API Key", + name: "override_customer_io_api_key", + required: false, + }, { + description: "Override default site id", + label: "Override Site ID", + name: "override_customer_io_site_id", + required: false, + }, + ] + return form + } + +} + +Hub.addAction(new CustomerIoTrackAction()) diff --git a/src/actions/customerio/test_customerio.ts b/src/actions/customerio/test_customerio.ts new file mode 100644 index 000000000..3cd5a4560 --- /dev/null +++ b/src/actions/customerio/test_customerio.ts @@ -0,0 +1,300 @@ +import * as chai from "chai" +import * as sinon from "sinon" + +import * as winston from "winston" +import * as Hub from "../../hub" +import { CustomerIoAction } from "./customerio" + +const action = new CustomerIoAction() +action.executeInOwnProcess = false + +function expectCustomerIoMatch(request: Hub.ActionRequest, match: any) { + const customerIoCallSpy = sinon.spy(async () => Promise.resolve()) + winston.debug(match) + const stubClient = sinon.stub(action as any, "customerIoClientFromRequest") + .callsFake(() => { + return {identify: customerIoCallSpy} + }) + const currentDate = new Date() + const clock = sinon.useFakeTimers(currentDate.getTime()) + return chai.expect(action.validateAndExecute(request)).to.be.fulfilled.then(() => { + stubClient.restore() + clock.restore() + }) +} + +describe(`${action.constructor.name} unit tests`, () => { + describe("action", () => { + + it("works with user_id", () => { + const request = new Hub.ActionRequest() + request.type = Hub.ActionType.Query + request.params = { + customer_io_api_key: "mykey", + customer_io_site_id: "mysiteId", + customer_io_region: "RegionEU", + } + + request.attachment = {dataBuffer: Buffer.from(JSON.stringify({ + fields: {dimensions: [{name: "coolfield", tags: ["user_id"]}]}, + data: [{coolfield: {value: 200}}]}))} + return expectCustomerIoMatch(request, { + id: 200, + }) + }) + + it("works with email", () => { + const request = new Hub.ActionRequest() + request.type = Hub.ActionType.Query + request.params = { + customer_io_api_key: "mykey", + customer_io_site_id: "mysiteId", + customer_io_region: "RegionEU", + } + request.attachment = {dataBuffer: Buffer.from(JSON.stringify({ + fields: {dimensions: [{name: "coolfield", tags: ["email"]}]}, + data: [{coolfield: {value: "funvalue"}}], + }))} + return expectCustomerIoMatch(request, { + userId: null, + }) + }) + + it("works with pivoted values", () => { + const request = new Hub.ActionRequest() + request.type = Hub.ActionType.Query + request.params = { + customer_io_api_key: "mykey", + customer_io_site_id: "mysiteId", + customer_io_region: "RegionEU", + } + request.attachment = {dataBuffer: Buffer.from(JSON.stringify({ + fields: {dimensions: [{name: "coolfield", tags: ["user_id"]}], + measures: [{name: "users.count"}]}, + data: [{"coolfield": {value: "funvalue"}, "users.count": {f: {value: 1}, z: {value: 3}}}], + }))} + return expectCustomerIoMatch(request, { + userId: "funvalue", + traits: { "users.count": [{ f: 1 }, { z: 3 }] }, + }) + }) + + it("works with email and user id", () => { + const request = new Hub.ActionRequest() + request.type = Hub.ActionType.Query + request.params = { + customer_io_api_key: "mykey", + customer_io_site_id: "mysiteId", + customer_io_region: "RegionEU", + } + request.attachment = {dataBuffer: Buffer.from(JSON.stringify({ + fields: {dimensions: [{name: "coolemail", tags: ["email"]}, {name: "coolid", tags: ["user_id"]}]}, + data: [{coolemail: {value: "email@email.email"}, coolid: {value: "id"}}], + }))} + return expectCustomerIoMatch(request, { + userId: "id", + traits: {email: "email@email.email"}, + }) + }) + + it("works with email, user id", () => { + const request = new Hub.ActionRequest() + request.type = Hub.ActionType.Query + request.params = { + customer_io_api_key: "mykey", + customer_io_site_id: "mysiteId", + customer_io_region: "RegionEU", + } + request.attachment = {dataBuffer: Buffer.from(JSON.stringify({ + fields: {dimensions: [ + {name: "coolemail", tags: ["email"]}, + {name: "coolid", tags: ["user_id"]}]}, + data: [{coolemail: {value: "email@email.email"}, coolid: {value: "id"}}], + }))} + return expectCustomerIoMatch(request, { + userId: "id", + traits: {email: "email@email.email"}, + }) + }) + + it("works with email, user id and trait", () => { + const request = new Hub.ActionRequest() + request.type = Hub.ActionType.Query + request.params = { + customer_io_api_key: "mykey", + customer_io_site_id: "mysiteId", + customer_io_region: "RegionEU", + } + request.attachment = {dataBuffer: Buffer.from(JSON.stringify({ + fields: {dimensions: [ + {name: "coolemail", tags: ["email"]}, + {name: "coolid", tags: ["user_id"]}, + {name: "cooltrait", tags: []}, + ]}, + data: [{ + coolemail: {value: "emailemail"}, + coolid: {value: "id"}, + cooltrait: {value: "funtrait"}, + }], + }))} + return expectCustomerIoMatch(request, { + userId: "id", + traits: { + email: "emailemail", + cooltrait: "funtrait", + }, + }) + }) + + it("works with user id", () => { + const request = new Hub.ActionRequest() + request.type = Hub.ActionType.Query + request.params = { + customer_io_api_key: "mykey", + customer_io_site_id: "mysiteId", + customer_io_region: "RegionEU", + } + request.attachment = {dataBuffer: Buffer.from(JSON.stringify({ + fields: {dimensions: [ + {name: "coolid", tags: ["user_id"]}, + ]}, + data: [ + {coolid: {value: "id"}}], + }))} + return expectCustomerIoMatch(request, { + userId: "id", + }) + }) + + it("doesn't send hidden fields", () => { + const request = new Hub.ActionRequest() + request.type = Hub.ActionType.Query + request.params = { + customer_io_api_key: "mykey", + customer_io_site_id: "mysiteId", + customer_io_region: "RegionEU", + } + request.attachment = {dataBuffer: Buffer.from(JSON.stringify({ + fields: { + dimensions: [ + {name: "coolfield", tags: ["email"]}, + {name: "hiddenfield"}, + {name: "nonhiddenfield"}, + ]}, + data: [{ + coolfield: {value: "funvalue"}, + hiddenfield: {value: "hiddenvalue"}, + nonhiddenfield: {value: "nonhiddenvalue"}, + }], + }))} + request.scheduledPlan = { + query: { + vis_config: { + hidden_fields: [ + "hiddenfield", + ], + }, + }, + } as any + return expectCustomerIoMatch(request, { + userId: null, + traits: { + email: "funvalue", + nonhiddenfield: "nonhiddenvalue", + }, + }) + }) + + it("works with null user_ids", () => { + const request = new Hub.ActionRequest() + request.type = Hub.ActionType.Query + request.params = { + customer_io_api_key: "mykey", + customer_io_site_id: "mysiteId", + customer_io_region: "RegionEU", + } + request.attachment = {dataBuffer: Buffer.from(JSON.stringify({ + fields: {dimensions: [{name: "coolfield", tags: ["user_id"]}]}, + data: [{coolfield: {value: null}}], + }))} + return expectCustomerIoMatch(request, { + userId: null, + }) + }) + + it("errors if the input has no attachment", () => { + const request = new Hub.ActionRequest() + request.type = Hub.ActionType.Query + request.params = { + customer_io_api_key: "mykey", + customer_io_site_id: "mysiteId", + customer_io_region: "RegionEU", + } + return chai.expect(action.validateAndExecute(request)).to.eventually + .be.rejectedWith( + "A streaming action was sent incompatible data. The action must have a download url or an attachment.") + }) + + it("errors if the query response has no fields", (done) => { + const request = new Hub.ActionRequest() + request.type = Hub.ActionType.Query + request.params = { + customer_io_api_key: "mykey", + customer_io_site_id: "mysiteId", + customer_io_region: "RegionEU", + } + request.attachment = {dataBuffer: Buffer.from(JSON.stringify({ + data: [{coolfield: {value: "funvalue"}}], + }))} + chai.expect(action.validateAndExecute(request)).to.eventually + .deep.equal({ + message: "Query requires a field tagged email or user_id.", + success: false, + refreshQuery: false, + validationErrors: [], + }) + .and.notify(done) + }) + + it("errors if there is no tagged field", (done) => { + const request = new Hub.ActionRequest() + request.type = Hub.ActionType.Query + request.params = { + customer_io_api_key: "mykey", + customer_io_site_id: "mysiteId", + customer_io_region: "RegionEU", + } + request.attachment = {dataBuffer: Buffer.from(JSON.stringify({ + fields: {dimensions: [{name: "coolfield", tags: []}]}, + data: [{coolfield: {value: "funvalue"}}], + }))} + chai.expect(action.validateAndExecute(request)).to.eventually + .deep.equal({ + message: "Query requires a field tagged email or user_id.", + success: false, + refreshQuery: false, + validationErrors: [], + }) + .and.notify(done) + }) + + it("errors if there is no site id", () => { + const request = new Hub.ActionRequest() + request.type = Hub.ActionType.Query + request.attachment = {dataBuffer: Buffer.from(JSON.stringify({ + fields: {dimensions: [{name: "coolfield", tags: ["user_id"]}]}, + data: [], + }))} + return chai.expect(action.validateAndExecute(request)).to.eventually + .be.rejectedWith(`Required setting "Site ID" not specified in action settings.`) + }) + + }) + + describe("form", () => { + it("has form", () => { + chai.expect(action.hasForm).equals(true) + }) + }) + +}) diff --git a/src/actions/customerio/test_customerio_track.ts b/src/actions/customerio/test_customerio_track.ts new file mode 100644 index 000000000..c75c7def6 --- /dev/null +++ b/src/actions/customerio/test_customerio_track.ts @@ -0,0 +1,52 @@ +import * as chai from "chai" +import * as sinon from "sinon" + +import * as Hub from "../../hub" +import { CustomerIoTrackAction } from "./customerio_track" + +const action = new CustomerIoTrackAction() +action.executeInOwnProcess = false + +describe(`${action.constructor.name} unit tests`, () => { + + describe("action", () => { + + it ("calls track", () => { + const customerIoCallSpy = sinon.spy(async () => Promise.resolve()) + const stubClient = sinon.stub(action as any, "customerIoClientFromRequest") + .callsFake(() => { + return {track: customerIoCallSpy, flush: (cb: () => void) => cb()} + }) + + const now = new Date() + const clock = sinon.useFakeTimers(now.getTime()) + + const request = new Hub.ActionRequest() + request.formParams = { + event: "funevent", + } + request.type = Hub.ActionType.Query + request.params = { + customer_io_api_key: "mykey", + customer_io_site_id: "mysiteId", + customer_io_region: "RegionEU", + } + request.attachment = {dataBuffer: Buffer.from(JSON.stringify({ + fields: {dimensions: [{name: "coolfield", tags: ["user_id"]}]}, + data: [{coolfield: {value: "funvalue"}}], + }))} + + return chai.expect(action.validateAndExecute(request)).to.be.fulfilled.then(() => { + stubClient.restore() + clock.restore() + }) + }) + }) + + describe("form", () => { + it("has form", () => { + chai.expect(action.hasForm).equals(true) + }) + }) + +}) diff --git a/src/actions/index.ts b/src/actions/index.ts index f2cfdb6a6..93ebeed74 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -4,6 +4,8 @@ import "./amazon/amazon_s3" import "./auger/auger_train" import "./azure/azure_storage" import "./braze/braze" +import "./customerio/customerio" +import "./customerio/customerio_track" import "./datarobot/datarobot" import "./digitalocean/digitalocean_droplet" import "./digitalocean/digitalocean_object_storage" diff --git a/test/test.ts b/test/test.ts index 0d6883c50..e9cf24c0d 100644 --- a/test/test.ts +++ b/test/test.ts @@ -25,6 +25,8 @@ import "../src/actions/amazon/test_amazon_s3" import "../src/actions/auger/test_auger_train" import "../src/actions/azure/test_azure_storage" import "../src/actions/braze/test_braze" +import "../src/actions/customerio/test_customerio" +import "../src/actions/customerio/test_customerio_track" import "../src/actions/datarobot/test_datarobot" import "../src/actions/digitalocean/test_digitalocean_droplet" import "../src/actions/digitalocean/test_digitalocean_object_storage" diff --git a/yarn.lock b/yarn.lock index e40fe8b16..6add35618 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1925,6 +1925,11 @@ csv-stringify@^1.0.4: dependencies: lodash.get "~4.4.2" +customerio-node@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/customerio-node/-/customerio-node-3.3.0.tgz#c2ae00370a600acef73fe73998eb4be3431aa52a" + integrity sha512-DV3B9JlJtn+vewDCC1QrDscR7/fTOr0S20aKEGs6PlzkepkXSMsoINLHOK5UXu6D2GUCYzP4UtoRCDXcuAjnVA== + cycle@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2"