Skip to content

Commit

Permalink
feat: twitter web conversions (#2220)
Browse files Browse the repository at this point in the history
* feat: twiiter web conversions

* feat: twiiter ads transformation

* fix: add oauth1 dev dependency

* fix: add oauth1 dev dependency

* fix: add oauth1 dev dependency

* fix: eslint fixes

* fix: addressed comments

* fix: addressed comments

* fix: populateEventId func return eventId

* fix: populateEventId func return eventId

* fix: sonar code smells

* fix: sonar code smells

* fix: replace some with find function

* fix: replace some with find function

* chore: remove unnecessary console.log

Signed-off-by: Sai Sankeerth <[email protected]>

---------

Signed-off-by: Sai Sankeerth <[email protected]>
Co-authored-by: Sai Sankeerth <[email protected]>
  • Loading branch information
aashishmalik and Sai Sankeerth authored Jun 15, 2023
1 parent 8572817 commit 3791dd4
Show file tree
Hide file tree
Showing 9 changed files with 1,031 additions and 0 deletions.
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"moment-timezone": "^0.5.43",
"node-cache": "^5.1.2",
"node-fetch": "^2.6.9",
"oauth-1.0a": "^2.2.6",
"object-hash": "^3.0.0",
"parse-static-imports": "^1.1.0",
"pprof": "^3.2.0",
Expand Down
8 changes: 8 additions & 0 deletions src/constants/destinationCanonicalNames.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ const DestCanonicalNames = {
],
vitally: ['vitally', 'Vitally', 'VITALLY'],
courier: ['Courier', 'courier', 'COURIER'],
TWITTER_ADS: [
'twitter ads',
'twitter Manager',
'TWITTER ADS',
'twitterADS',
'twitter_ads',
'TWITTER_ADS',
],
};

module.exports = { DestHandlerMap, DestCanonicalNames };
18 changes: 18 additions & 0 deletions src/v0/destinations/twitter_ads/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const { getMappingConfig } = require('../../util');

const BASE_URL = 'https://ads-api.twitter.com/12/measurement/conversions';

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

const mappingConfig = getMappingConfig(ConfigCategories, __dirname);

module.exports = {
mappingConfig,
ConfigCategories,
BASE_URL
};
45 changes: 45 additions & 0 deletions src/v0/destinations/twitter_ads/data/TwitterAdsTrackConfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
[
{
"destKey": "event_id",
"sourceKeys": "properties.eventId",
"required": false
},
{
"destKey": "conversion_time",
"sourceKeys": "properties.conversionTime",
"required": false
},
{
"destKey": "number_items",
"sourceKeys": ["properties.quantity", "properties.numberItems"],
"metadata": {
"type": "toNumber"
},
"required": false
},
{
"destKey": "price_currency",
"sourceKeys": ["properties.currency"],
"required": false
},
{
"destKey": "value",
"sourceKeys": ["properties.value", "properties.price", "properties.revenue"],
"required": false
},
{
"destKey": "conversion_id",
"sourceKeys": "properties.conversionId",
"required": false
},
{
"destKey": "description",
"sourceKeys": "properties.description",
"required": false
},
{
"destKey": "contents",
"sourceKeys": "properties.contents",
"required": false
}
]
180 changes: 180 additions & 0 deletions src/v0/destinations/twitter_ads/transform.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
const sha256 = require('sha256');

const {
constructPayload,
defaultRequestConfig,
defaultPostRequestConfig,
removeUndefinedAndNullValues,
isDefinedAndNotNull,
simpleProcessRouterDest,
} = require('../../util');
const { EventType } = require('../../../constants');
const {
ConfigCategories,
mappingConfig,
BASE_URL
} = require('./config');

const { InstrumentationError, OAuthSecretError, ConfigurationError } = require('../../util/errorTypes');
const { JSON_MIME_TYPE } = require('../../util/constant');
const { getAuthHeaderForRequest } = require("./util");

const getOAuthFields = ({ secret }) => {
if (!secret) {
throw new OAuthSecretError('[TWITTER ADS]:: OAuth - access keys not found');
}
const oAuthObject = {
consumerKey: secret.consumerKey,
consumerSecret: secret.consumerSecret,
accessToken: secret.accessToken,
accessTokenSecret: secret.accessTokenSecret
};
return oAuthObject;
};

// build final response
function buildResponse(message, requestJson, metadata, endpointUrl) {
const response = defaultRequestConfig();
response.endpoint = endpointUrl;
response.method = defaultPostRequestConfig.requestMethod;
response.body.JSON.conversions = [removeUndefinedAndNullValues(requestJson)];
// required to be in accordance with oauth package
const request = {
url: response.endpoint,
method: response.method,
body: response.body.JSON
};

const oAuthObject = getOAuthFields(metadata);
const authHeader = getAuthHeaderForRequest(request, oAuthObject).Authorization;
response.headers = {
Authorization: authHeader,
'Content-Type': JSON_MIME_TYPE,
};
return response;
}

function prepareUrl(message, destination) {
const pixelId = message.properties.pixelId || destination.Config.pixelId;
return `${BASE_URL}/${pixelId}`;
}

function populateEventId(event, requestJson, destination) {

const eventNameToIdMappings = destination.Config.twitterAdsEventNames;
let eventId = "";

if (eventNameToIdMappings) {
const eventObj = eventNameToIdMappings.find(obj => obj.rudderEventName?.trim().toLowerCase() === event?.toString().toLowerCase());
eventId = eventObj?.twitterEventId;
}

if(!eventId) {
throw new ConfigurationError(`[TWITTER ADS]: Event - '${event}' do not have a corresponding eventId in configuration. Aborting`);
}

return eventId;
}

function populateContents(requestJson) {
const reqJson = { ...requestJson };
if (reqJson.contents) {
const transformedContents = requestJson.contents.map(obj => ({
...(obj.id && { content_id: obj.id }),
...(obj.groupId && { content_group_id: obj.groupId }),
...(obj.name && { content_name: obj.name }),
...(obj.price && { content_price: parseFloat(obj.price) }),
...(obj.type && { content_type: obj.type }),
...(obj.quantity && { num_items: parseInt(obj.quantity, 10) })
})).filter(tfObj => Object.keys(tfObj).length > 0);
if (transformedContents.length > 0) {
reqJson.contents = transformedContents;
}
}
return reqJson;
}

// process track call
function processTrack(message, metadata, destination) {

let requestJson = constructPayload(message, mappingConfig[ConfigCategories.TRACK.name]);

requestJson.event_id = requestJson.event_id || populateEventId(message.event, requestJson, destination);

requestJson.conversion_time = isDefinedAndNotNull(requestJson.conversion_time)
? requestJson.conversion_time : message.timestamp;

const identifiers = [];

if (message.properties.email) {
let email = message.properties.email.trim();
if (email) {
email = email.toLowerCase();
identifiers.push({hashed_email: sha256(email)})
}
}

if (message.properties.phone) {
const phone = message.properties.phone.trim();
if (phone) {
identifiers.push({hashed_phone_number: sha256(phone)})
}
}

if (message.properties.twclid) {
identifiers.push({twclid: sha256(message.properties.twclid)});
}

requestJson = populateContents(requestJson);

requestJson.identifiers = identifiers;

const endpointUrl = prepareUrl(message, destination);

return buildResponse(
message,
requestJson,
metadata,
endpointUrl
);
}

function validateRequest(message) {

const { properties } = message;

if (!properties) {
throw new InstrumentationError(
'[TWITTER ADS]: properties must be present in event. Aborting message',
);
}

if (!properties.email && !properties.phone && !properties.twclid) {
throw new InstrumentationError(
'[TWITTER ADS]: one of twclid, phone or email must be present in properties.',
);
}
}

function process(event) {

const { message, metadata, destination } = event;

validateRequest(message);

const messageType = message.type?.toLowerCase();

if (messageType === EventType.TRACK) {
return processTrack(message, metadata, destination);
}

throw new InstrumentationError(`Message type ${messageType} not supported`);

}

const processRouterDest = async (inputs, reqMetadata) => {
const respList = await simpleProcessRouterDest(inputs, process, reqMetadata);
return respList;
};

module.exports = { process, processRouterDest };
24 changes: 24 additions & 0 deletions src/v0/destinations/twitter_ads/util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const crypto = require('crypto');
const oauth1a = require('oauth-1.0a');

function getAuthHeaderForRequest(request, oAuthObject) {
const oauth = oauth1a({
consumer: { key: oAuthObject.consumerKey, secret: oAuthObject.consumerSecret },
signature_method: 'HMAC-SHA1',
hash_function(base_string, k) {
return crypto
.createHmac('sha1', k)
.update(base_string)
.digest('base64')
},
})

const authorization = oauth.authorize(request, {
key: oAuthObject.accessToken,
secret: oAuthObject.accessTokenSecret,
});

return oauth.toHeader(authorization);
}

module.exports = { getAuthHeaderForRequest };
Loading

0 comments on commit 3791dd4

Please sign in to comment.