Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: onboard smartly destination #3660

Merged
merged 19 commits into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
node_modules/

Check warning on line 1 in .eslintignore

View workflow job for this annotation

GitHub Actions / Check for formatting & lint errors

File ignored by default.
.husky/
reports/
test/
Expand All @@ -21,3 +21,8 @@
test/integrations/destinations/testTypes.d.ts
*.config*.js
scripts/skipPrepareScript.js
*.yaml
*.yml
.eslintignore
.prettierignore
*.json
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{

Check warning on line 1 in .eslintrc.json

View workflow job for this annotation

GitHub Actions / Check for formatting & lint errors

File ignored by default.
"env": {
"node": true,
"es2021": true,
Expand All @@ -19,7 +19,8 @@
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module",
"project": "./tsconfig.json"
"project": "./tsconfig.json",
"extraFileExtensions": [".yaml"]
},
"rules": {
"unicorn/filename-case": [
Expand Down
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
node_modules/

Check warning on line 1 in .prettierignore

View workflow job for this annotation

GitHub Actions / Check for formatting & lint errors

File ignored by default.
reports/
CHANGELOG.md
CONTRIBUTING.md
Expand All @@ -8,3 +8,5 @@
src/util/lodash-es-core.js
src/util/url-search-params.min.js
dist
.eslintignore
.prettierignore
21 changes: 21 additions & 0 deletions src/cdk/v2/destinations/smartly/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const { getMappingConfig } = require('../../../../v0/util');

const ConfigCategories = {
TRACK: {
type: 'track',
name: 'trackMapping',
},
};

const mappingConfig = getMappingConfig(ConfigCategories, __dirname);
const singleEventEndpoint = 'https://s2s.smartly.io/events';
aanshi07 marked this conversation as resolved.
Show resolved Hide resolved
const batchEndpoint = 'https://s2s.smartly.io/events/batch';

module.exports = {
ConfigCategories,
mappingConfig,
singleEventEndpoint,
batchEndpoint,
TRACK_CONFIG: mappingConfig[ConfigCategories.TRACK.name],
MAX_BATCH_SIZE: 1000,
};
76 changes: 76 additions & 0 deletions src/cdk/v2/destinations/smartly/data/trackMapping.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
[
{
"destKey": "value",
"sourceKeys": [
"properties.total",
"properties.value",
"properties.revenue",
{
"operation": "multiplication",
"args": [
{
"sourceKeys": "properties.price"
},
{
"sourceKeys": "properties.quantity",
"default": 1
}
]
}
],
"metadata": {
"type": "toNumber"
},
"required": false
},
{
"sourceKeys": ["properties.conversions", "properties.products.length"],
"required": false,
"metadata": {
"defaultValue": "1"
aanshi07 marked this conversation as resolved.
Show resolved Hide resolved
},
"destKey": "conversions"
},
{
"sourceKeys": ["properties.adUnitId", "properties.ad_unit_id"],
"required": true,
"destKey": "ad_unit_id",
"metadata": {
"type": "toString"
}
},
{
"sourceKeys": ["properties.platform"],
"required": true,
"destKey": "platform"
},
{
"sourceKeys": ["properties.adInteractionTime", "properties.ad_interaction_time"],
aanshi07 marked this conversation as resolved.
Show resolved Hide resolved
"required": true,
"metadata": {
"type": "secondTimestamp"
},
"destKey": "ad_interaction_time"
},
{
"sourceKeys": ["properties.installTime"],
aanshi07 marked this conversation as resolved.
Show resolved Hide resolved
"required": false,
"metadata": {
"type": "secondTimestamp"
},
"destKey": "installTime"
},
{
"sourceKeys": ["originalTimestamp", "timestamp"],
"required": false,
"metadata": {
"type": "secondTimestamp"
},
"destKey": "event_time"
aanshi07 marked this conversation as resolved.
Show resolved Hide resolved
},
{
"sourceKeys": ["properties.currency"],
"required": false,
"destKey": "value_currency"
}
]
31 changes: 31 additions & 0 deletions src/cdk/v2/destinations/smartly/procWorkflow.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
bindings:
- name: EventType
path: ../../../../constants
- path: ../../bindings/jsontemplate
- name: defaultRequestConfig
path: ../../../../v0/util
- name: removeUndefinedAndNullValues
path: ../../../../v0/util
- name: constructPayload
path: ../../../../v0/util
- path: ./config
- path: ./utils
steps:
- name: messageType
template: |
.message.type.toLowerCase();
- name: validateInput
template: |
let messageType = $.outputs.messageType;
$.assert(messageType, "message Type is not present. Aborting");
$.assert(messageType in {{$.EventType.([.TRACK])}}, "message type " + messageType + " is not supported");
$.assertConfig(.destination.Config.apiToken, "API Token is not present. Aborting");
- name: preparePayload
template: |
const payload = $.removeUndefinedAndNullValues($.constructPayload(.message, $.TRACK_CONFIG));
$.verifyAdInteractionTime(payload.ad_interaction_time);
$.context.payloadList = $.getPayloads(.message.event, .destination.Config, payload)
- name: buildResponse
template: |
const response = $.buildResponseList($.context.payloadList)
response
35 changes: 35 additions & 0 deletions src/cdk/v2/destinations/smartly/rtWorkflow.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
bindings:
- path: ./config
- name: handleRtTfSingleEventError
path: ../../../../v0/util/index
- path: ./utils
steps:
- name: validateInput
template: |
$.assert(Array.isArray(^) && ^.length > 0, "Invalid event array")

- name: transform
externalWorkflow:
path: ./procWorkflow.yaml
loopOverInput: true

- name: successfulEvents
template: |
$.outputs.transform#idx.output.({
"output": .body.JSON,
"destination": ^[idx].destination,
"metadata": ^[idx].metadata
})[]
- name: failedEvents
template: |
$.outputs.transform#idx.error.(
$.handleRtTfSingleEventError(^[idx], .originalError ?? ., {})
)[]
- name: batchSuccessfulEvents
description: Batches the successfulEvents
template: |
$.batchResponseBuilder($.outputs.successfulEvents);

- name: finalPayload
template: |
[...$.outputs.failedEvents, ...$.outputs.batchSuccessfulEvents]
108 changes: 108 additions & 0 deletions src/cdk/v2/destinations/smartly/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
const { BatchUtils } = require('@rudderstack/workflow-engine');
const { InstrumentationError } = require('@rudderstack/integrations-lib');
const moment = require('moment');
const config = require('./config');
const {
getHashFromArrayWithDuplicate,
defaultRequestConfig,
isDefinedAndNotNull,
} = require('../../../../v0/util');

// docs reference : https://support.smartly.io/hc/en-us/articles/4406049685788-S2S-integration-API-description#01H8HBXZF6WSKSYBW1C6NY8A88

/**
* This function generates an array of payload objects, each with the event property set
* to different values associated with the given event name according to eventsMapping
* @param {*} event
* @param {*} eventsMapping
* @param {*} payload
* @returns
*/
const getPayloads = (event, Config, payload) => {
if (!isDefinedAndNotNull(event) || typeof event !== 'string') {
throw new InstrumentationError('Event is not defined or is not String');
}
const eventsMap = getHashFromArrayWithDuplicate(Config.eventsMapping);
// eventsMap = hashmap {"prop1":["val1","val2"],"prop2":["val2"]}
const eventList = Array.isArray(eventsMap[event.toLowerCase()])
? eventsMap[event.toLowerCase()]
: Array.from(eventsMap[event.toLowerCase()] || [event]);

const payloadLists = eventList.map((ev) => ({ ...payload, event_name: ev }));
return payloadLists;
};

// ad_interaction_time must be within one year in the future and three years in the past from the current date
// Example : "1735680000"
const verifyAdInteractionTime = (adInteractionTime) => {
if (isDefinedAndNotNull(adInteractionTime)) {
const now = moment();
const threeYearAgo = now.clone().subtract(3, 'year');
const oneYearFromNow = now.clone().add(1, 'year');
const inputMoment = moment(adInteractionTime * 1000); // Convert to milliseconds
if (!inputMoment.isAfter(threeYearAgo) || !inputMoment.isBefore(oneYearFromNow)) {
throw new InstrumentationError(
'ad_interaction_time must be within one year in the future and three years in the past.',
);
}
}
};

const buildResponseList = (payloadList) =>
payloadList.map((payload) => {
const response = defaultRequestConfig();
response.body.JSON = payload;
response.endpoint = config.singleEventEndpoint;
response.method = 'POST';
return response;
});

const batchBuilder = (batch, destination) => ({
batchedRequest: {
body: {
JSON: { events: batch.map((event) => event.output) },
JSON_ARRAY: {},
XML: {},
FORM: {},
},
version: '1',
type: 'REST',
method: 'POST',
endpoint: config.batchEndpoint,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${destination.Config.apiToken}`,
},
params: {},
files: {},
},
metadata: batch
.map((event) => event.metadata)
.filter((metadata, index, self) => self.findIndex((m) => m.jobId === metadata.jobId) === index), // handling jobId duplication for multiplexed events
batched: true,
statusCode: 200,
destination: batch[0].destination,
});

/**
* This fucntions make chunk of successful events based on MAX_BATCH_SIZE
* and then build the response for each chunk to be returned as object of an array
* @param {*} events
* @returns
*/
const batchResponseBuilder = (events) => {
if (events.length === 0) {
return [];
}
const { destination } = events[0];
const batches = BatchUtils.chunkArrayBySizeAndLength(events, { maxItems: config.MAX_BATCH_SIZE });

const response = [];
batches.items.forEach((batch) => {
const batchedResponse = batchBuilder(batch, destination);
response.push(batchedResponse);
});
return response;
};

module.exports = { batchResponseBuilder, getPayloads, buildResponseList, verifyAdInteractionTime };
59 changes: 59 additions & 0 deletions src/cdk/v2/destinations/smartly/utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const moment = require('moment');
const { verifyAdInteractionTime } = require('./utils');

describe('verifyAdInteractionTime', () => {
it('should pass when adInteractionTime is 2 years in the past (UNIX timestamp)', () => {
// 2 years ago from now
const adInteractionTime = moment().subtract(2, 'years').unix();
expect(() => verifyAdInteractionTime(adInteractionTime)).not.toThrow();
});

it('should pass when adInteractionTime is 10 months in the future (UNIX timestamp)', () => {
// 10 months in the future from now
const adInteractionTime = moment().add(10, 'months').unix();
expect(() => verifyAdInteractionTime(adInteractionTime)).not.toThrow();
});

it('should fail when adInteractionTime is 4 years in the past (UNIX timestamp)', () => {
// 4 years ago from now
const adInteractionTime = moment().subtract(4, 'years').unix();
expect(() => verifyAdInteractionTime(adInteractionTime)).toThrow(
'ad_interaction_time must be within one year in the future and three years in the past.',
);
});

it('should fail when adInteractionTime is 2 years in the future (UNIX timestamp)', () => {
// 2 years in the future from now
const adInteractionTime = moment().add(2, 'years').unix();
expect(() => verifyAdInteractionTime(adInteractionTime)).toThrow(
'ad_interaction_time must be within one year in the future and three years in the past.',
);
});

it('should pass when adInteractionTime is exactly 1 year in the future (UTC date string)', () => {
// Exactly 1 year in the future from now
const adInteractionTime = moment.utc().add(1, 'year').toISOString();
expect(() => verifyAdInteractionTime(adInteractionTime)).toThrow();
});

it('should fail when adInteractionTime is 4 years in the past (UTC date string)', () => {
// 4 years ago from now
const adInteractionTime = moment.utc().subtract(4, 'years').toISOString();
expect(() => verifyAdInteractionTime(adInteractionTime)).toThrow(
'ad_interaction_time must be within one year in the future and three years in the past.',
);
});

it('should fail when adInteractionTime is 2 years in the future (UTC date string)', () => {
// 2 years in the future from now
const adInteractionTime = moment.utc().add(2, 'years').toISOString();
expect(() => verifyAdInteractionTime(adInteractionTime)).toThrow(
'ad_interaction_time must be within one year in the future and three years in the past.',
);
});

it('should not throw an error if adInteractionTime is null or undefined', () => {
expect(() => verifyAdInteractionTime(null)).not.toThrow();
expect(() => verifyAdInteractionTime(undefined)).not.toThrow();
});
});
3 changes: 2 additions & 1 deletion src/features.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@
"CLICKSEND": true,
"ZOHO": true,
"CORDIAL": true,
"BLOOMREACH_CATALOG": true
"BLOOMREACH_CATALOG": true,
"SMARTLY": true
},
"regulations": [
"BRAZE",
Expand Down
Loading
Loading