Retry CTR Sync for Voice Call Records
Ease of Implementation | Advanced |
Estimated Time to Implement | 2–3 hours |
Contact Trace Records are generated on Amazon for each call. This data contains details about the call, including caller and rep information, call statistics, and queue information. The CTRDataSyncFunction Lambda function automatically runs for each call to store this data in a Voice Call record. CTRs aren’t retained by Amazon, and if the CTR sync process fails for any reason, you can lose important information about the call.
This solution provides a way to reattempt a sync of the CTR data by first backing up CTR data in a separate Amazon S3 bucket, then checking for any sync failures, and then re-uploading CTR data whenever necessary.
Prerequisites
- Have your AWS root user or AWS administrator credentials ready.
- Be familiar with Amazon S3 buckets. See Amazon’s documentation: Creating and configuring an S3 bucket.
- Be familiar with modifying Amazon Connect Lambda functions. See Amazon’s documentation: AWS Lambda Developer Guide.
- Configure the OAuth support for the InvokeSalesforceRestApiFunction Lambda function. This step is required to support the getAccessToken function.
Step 1: Create an Amazon S3 Bucket
- Under General Configuration > Bucket Type, select General Purpose.
- Under Object Ownership, select ACLs disabled (recommended).
- Under Block Public Access settings for this bucket, select Block all public access.
- Under Bucket Versioning, select Enable.
- Under Default Encryption > Encryption Type, select Server-side encryption with Amazon S3 managed keys (SSE-S3).
- Under Default Encryption > Bucket Key, select Enable.
See Amazon’s documentation: Creating and configuring an S3 bucket.
When using the code provided by your S3 bucket service in AWS, replace the resource account ID and ARN with your account ID and S3 bucket ARN, respectively. Delete all code comments before you save your changes.
{
"Version": "2008-10-17",
"Statement": [
{
"Sid": "PutObjectAccess",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::<your account id>:root" // REPLACE THIS WITH YOUR ACCOUNT ID
},
"Action": "s3:*",
"Resource": "<your S3 Bucket ARN>" // REPLACE THIS WITH YOUR S3 BUCKET ARN
},
{
"Effect": "Allow",
"Principal": {
"Service": "transcribe.amazonaws.com"
},
"Action": "s3:*",
"Resource": "<your S3 Bucket ARN>" // REPLACE THIS WITH YOUR S3 BUCKET ARN
}
]
}
Step 2: Set Data Expiration
After you store the CTR data in the S3 bucket, it’s a good idea to purge them periodically. To remove old data, specify the object expiration for the S3 bucket and define how long to wait before the files are deleted. See Amazon’s documentation: Expiring Objects and Managing your storage lifecycle.
Step 3: Save Contract Trace Records in S3
The CTRDataSyncFunction is configured to receive events from the Kinesis stream. These events are generated whenever a CTR is generated. Modify this Lambda function to read those events and write them to the S3 bucket as they’re generated. Update the handler.js file so that it contains a new function, writeCTRToS3, that writes data to the new bucket. Then, update the existing code to call this new function.
When modifying existing code, it's crucial to add code in the correct context. Inserting code snippets out of context or in the wrong order breaks the code.
In this example, the text in bold is where the new writeCTRToS3 function must be added to the handler.js file. Delete all code comments before you save your changes.
const aws = require("aws-sdk");
const SCVLoggingUtil = require("./SCVLoggingUtil");
const s3 = new aws.S3();
const lambda = new aws.Lambda();
const utils = require("./utils");
function updateVoiceCallRecord(voiceCall) {
SCVLoggingUtil.info({
message: "CTR/updateVoiceCallRecord Request created",
context: { contactId: voiceCall.contactId },
});
const payload = {
Details: {
Parameters: {
methodName: "updateVoiceCall",
fieldValues: voiceCall.fields,
contactId: voiceCall.contactId,
},
},
};
const params = {
FunctionName: process.env.INVOKE_TELEPHONY_INTEGRATION_API_ARN,
Payload: JSON.stringify(payload),
};
return lambda.invoke(params).promise();
}
function shouldProcessCtr(ctrRecord) {
return (
["INBOUND", "OUTBOUND", "TRANSFER", "CALLBACK", "API"].includes(
ctrRecord.InitiationMethod
) &&
ctrRecord.ContactId &&
// if the call is a voicemail, no need to process it because the packager lambda updates the voicecall record
!(
ctrRecord.Attributes &&
ctrRecord.Attributes.vm_flag &&
ctrRecord.Recordings
)
);
}
exports.handler = async (event) => {
const promises = [];
SCVLoggingUtil.debug({
message: "CTRDataSync event received",
context: event,
});
event.Records.forEach((record) => {
const ctr = utils.parseData(record.kinesis.data);
if (shouldProcessCtr(ctr)) {
const voiceCall = utils.transformCTR(ctr);
SCVLoggingUtil.debug({
category: "ctrDataSync.handler",
message: "Transformed CTR to voice call",
context: voiceCall,
});
const updatePromise = updateVoiceCallRecord(voiceCall);
promises.push(updatePromise);
updatePromise.then((response) => {
SCVLoggingUtil.info({
message: "updateVoiceCallRecord response",
context: response,
});
});
writeCTRToS3(ctr);
} else {
SCVLoggingUtil.error({
message: "Encountered Non supported CTR Events: failing fast",
context: {},
});
}
});
return Promise.all(promises);
};
function writeCTRToS3(ctr) {
var params = {
Bucket : "<your S3 bucket Name>", // REPLACE THIS WITH YOUR S3 BUCKET NAME
Key: ctr.ContactId,
Body: Buffer.from(JSON.stringify(ctr)),
}
s3.putObject(params, function(err, data) {
if (err) {
console.log("Error uploading CTR to S3: " + JSON.stringify(err));
} else {
console.log("Successfully uploaded CTR to S3: " + JSON.stringify(data));
}
});
}
exports.shouldProcessCtr = shouldProcessCtr;
There’s one object in S3 for every CTR generated. The object is named with the ContactId of the CTR, so it’s easy to look up.
In the CTRDataSyncFunction associated with your contact center, create a new inline policy for the IAM role and delete any comments in the code before saving:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:ListBucket",
"s3:PutObjectAcl"
],
"Resource": "<your S3 Bucket ARN>/*" // REPLACE THIS WITH YOUR S3 BUCKET ARN
}
]
}
Step 4: Re-Sync Voice Calls That Aren’t Synced
Modify the InvokeSalesforceRestApiFunction Lambda to perform three key actions:
- Query for Voice Calls that aren’t synced. Query for all Voice Call records
(over a given time period) that weren’t updated by the CTRDataSyncFunction.
To identify Voice Calls that aren’t updated, you can filter by fields that
are only updated during a CTR sync. In this example, we specify a condition
based on CustomerHoldDuration.
SELECT Id, VendorCallKey FROM VoiceCall WHERE CustomerHoldDuration = null
- Fetch CTRs from S3. If there are results returned from the previous step, then read from the S3 bucket to fetch CTRs for the given Voice Call records. Look up this data with the VendorCallKey.
- Update Voice Call records in the org. Update Voice Call records based on the information returned from the previous step.
This code updates to InvokeSalesforceRestApiFunction perform all these actions.
Updates to sfRestApi.js. In-context changes are in bold. Remove any comments in the code before saving.
const utils = require("./utils");
const axiosWrapper = require("./axiosWrapper");
const SCVLoggingUtil = require("./SCVLoggingUtil");
const aws = require('aws-sdk');
const lambda = new aws.Lambda();
const s3 = new aws.S3();
….
async function searchRecord(sosl) {
try {
const response = await sendRequest(
"get",
`/search/?q=${encodeURIComponent(sosl)}`
);
SCVLoggingUtil.debug({
category: "sfRestApi.searchRecord",
message: "search Record response",
context: response,
});
if (response.data.searchRecords.length === 0) {
return {};
}
return response.data.searchRecords[0];
} catch (e) {
return buildError(e);
}
}
// NEW function that performs CTR sync retry
async function retryCtrSync(soql) {
// Query For VoiceCalls that are missing CTR Sync
const response = await sendRequest(
"get",
`/query/?q=${encodeURIComponent(soql)}`
);
console.log("Querying for VoiceCalls that were not updated");
if (response.data.errorType) {
console.log("Error querying VoiceCalls: " + response.data.errorMessage);
return {
success: false,
errorMessage: response.data.errorMessage
};
} else if (response.data.totalSize === 0) {
console.log("No VoiceCalls were found that were missing CTR update");
return {};
} else {
console.log("Successfully queried for VoiceCalls");
const result = response.data.records;
// Fetch CTRs from S3
var ctrRecords = await getCtrsFromS3(result);
// Update VoiceCalls with the CTRs fetched from S3
var results = await updateVoiceCallRecords(ctrRecords);
return results;
}
}
// NEW Function to fetch CTRs from S3
async function getCtrsFromS3(result) {
var ctrData = [];
for (var i=0; i<result.length; i++) {
console.log("Vendor Call Key " + result[i].VendorCallKey);
var getParams = {
Bucket: '<your S3 bucket Name>', // your bucket name,
Key: result[i].VendorCallKey // path to the object you're looking for
}
await s3.getObject(getParams, function(err, data1) {
if (err) {
console.log("ERROR in querying S3 " + err);
return err;
} else {
var objectData = data1.Body.toString('utf-8'); // Use the encoding necessary
var ctrObj = JSON.parse(objectData);
console.log("CTR found for ContactId: " + ctrObj.ContactId);
ctrData.push(ctrObj);
}
}).promise();
}
return ctrData;
}
// NEW function to update VoiceCall Records
async function updateVoiceCallRecords(ctrRecords) {
let promises = [];
ctrRecords.forEach((record) => {
const ctr = record;
if (ctr.ContactId) {
const voiceCall = utils.transformCTR(ctr);
promises.push(updateVoiceCallRecord(voiceCall));
} else {
console.log("No ContactId found in CTR");
}
});
return await Promise.all(promises);
}
// UPDATED: Add environment variable
function updateVoiceCallRecord(voiceCall) {
console.log("Updating VoiceCall");
const payload = {
Details: {
Parameters: {
methodName: 'updateVoiceCall',
fieldValues: voiceCall.fields,
contactId: voiceCall.contactId
}
}
};
const params = {
// NEW: Create this as an environment variable
// or hard-code the ARN of the Lambda
FunctionName: process.env.INVOKE_TELEPHONY_INTEGRATION_API_ARN,
Payload: JSON.stringify(payload)
};
return lambda.invoke(params).promise();
}
// UPDATED: Include the new function (retryCtrSync)
module.exports = {
createRecord,
updateRecord,
queryRecord,
searchRecord,
sendRealtimeAlertEvent,
retryCtrSync,
};
Updates to QueryEngine.js. In-context changes are in bold. Remove any comments in the code before saving.
const api = require("./sfRestApi");
const SCVLoggingUtil = require("./SCVLoggingUtil");
function formatQuery(args, queryStr) {
let query;
Object.keys(args).forEach((key) => {
const replacement = `{${key}}`;
query = queryStr.replace(replacement, args[key]);
});
return query;
}
// invokes the query from sf rest api
// can take the query as a formatted string of sorts,
// replacing {key} with its value in the js object
async function invokeQuery(query, args) {
const formattedQuery = formatQuery(args, query);
SCVLoggingUtil.debug({
message: "invoke query from SfRestApi",
context: { payload: formattedQuery },
});
return api.queryRecord(formattedQuery);
}
// NEW function to that exposes the Retry CTR
async function retryCTRSync(query, args) {
var formattedQuery = formatQuery(args, query);
return await api.retryCtrSync(formattedQuery);
}
// UPDATED: Export the new function (retryCtrSync)
module.exports={
invokeQuery,
formatQuery,
retryCTRSync
};
Updates to Handler.js. In-context changes are in bold:
const flatten = require("flat");
const SCVLoggingUtil = require("./SCVLoggingUtil");
const api = require("./sfRestApi");
const queryEngine = require("./queryEngine");
const utils = require("./utils");
const SFSPhoneCallFlow = require("./SFSPhoneCallFlow");
// --------------- Events -----------------------
async function dispatchSearch(sosl) {
const searchResult = await api.searchRecord(sosl);
return flatten(searchResult);
}
async function dispatch_ctrsync(soql, event){
const parameters = event.Details.Parameters;
console.log(event)
let response;
try {
const queryResult = await queryEngine.retryCTRSync(soql, parameters);
response = {
statusCode: 200,
result: queryResult
}
}
catch (e) {
response = {
statusCode: e.response && e.response.status ? e.response.status : 500,
result: e
}
}
return flatten(response);
}
// --------------- Main handler -----------------------;
case "SFSPhoneCallFlowQuery": {
const res = await SFSPhoneCallFlow.entryPoint(event);
result = flatten(res);
break;
}
case 'retryCTRSync': {
result = dispatch_ctrsync(soql, event);
break;
}
default: {
SCVLoggingUtil.warn({
message: "Unsupported method",
context: { payload: event },
});
throw new Error(`Unsupported method: ${methodName}`);
}
}
if (result.success === false) {
throw new Error(result.errorMessage);
} else {
return result;
}
};
}
Updates to utils.js. In-context changes are in bold. Remove any comments in the code before saving.
The transformCTR and getCallAttributes functions are copied over from the CTRDataSyncFunction utils.js file. (Alternatively, directly modify CTRDataSyncFunction to support taking a single CTR and updating the Voice Call record from there.) These functions must be inserted under the declaration of the getRealtimeAlertEventFieldValuesFromConnectLambdaParams function.
function getRealtimeAlertEventFieldValuesFromConnectLambdaParams(params) {
const fieldValues = {};
Object.entries(params).forEach((entry) => {
const key = entry[0];
if (key !== "methodName") {
fieldValues[key] = entry[1];
}
});
return fieldValues;
}
// COPIED function to transform CTR
function transformCTR(ctr) {
const voiceCall = {};
voiceCall.startTime = ctr.InitiationTimestamp;
voiceCall.endTime = ctr.DisconnectTimestamp;
voiceCall.parentCallIdentifier = ctr.PreviousContactId;
if (ctr.Agent) {
voiceCall.acceptTime = ctr.Agent.ConnectedToAgentTimestamp;
voiceCall.totalHoldDuration = ctr.Agent.CustomerHoldDuration;
voiceCall.longestHoldDuration = ctr.Agent.LongestHoldDuration;
voiceCall.agentInteractionDuration = ctr.Agent.AgentInteractionDuration;
voiceCall.numberOfHolds = ctr.Agent.NumberOfHolds;
}
if (ctr.Queue) {
voiceCall.enqueueTime = ctr.Queue.EnqueueTimestamp;
voiceCall.queue = ctr.Queue.Name;
}
if (ctr.InitiationMethod) {
voiceCall.initiationMethod = ctr.InitiationMethod;
if (ctr.InitiationMethod === 'OUTBOUND') {
if (ctr.SystemEndpoint) {
voiceCall.fromNumber = ctr.SystemEndpoint.Address;
}
if ( ctr.CustomerEndpoint) {
voiceCall.toNumber = ctr.CustomerEndpoint.Address;
}
} else {
if (ctr.SystemEndpoint) {
voiceCall.toNumber = ctr.SystemEndpoint.Address;
}
if ( ctr.CustomerEndpoint) {
voiceCall.fromNumber = ctr.CustomerEndpoint.Address;
}
}
}
if (ctr.Recording) {
voiceCall.recordingLocation = ctr.Recording.Location;
}
// Check if there are custom contact attributes
if (ctr.Attributes) {
var callAttributes = {};
// Get contact attributes data into call attributes
callAttributes = getCallAttributes(ctr.Attributes);
voiceCall.callAttributes = callAttributes;
}
Object.keys(voiceCall).forEach(function (key) {
if (voiceCall[key] === null || voiceCall[key] === undefined) {
delete voiceCall[key];
}
});
return {contactId: ctr.ContactId, fields: voiceCall};
}
// COPIED function to filter call attributes
/**
* Filter call attributes to be included in API payload based on prefix and strip prefix
*
* @param {object} rawCallAttributes - Contact flow attributes
*
* @return {string} - Stringified contact flow attributes with prefix removed
*/
function getCallAttributes(rawCallAttributes) {
const prefix = 'sfdc-';
const prefixLen = prefix.length;
let callAttributes = {};
for (const key in rawCallAttributes) {
if (rawCallAttributes.hasOwnProperty(key) && key.startsWith(prefix)) {
callAttributes[key.substring(prefixLen)] = rawCallAttributes[key];
}
// Set SCV Limits Error if the specific contact attribute is set
if (rawCallAttributes.sf_realtime_transcription_status) {
callAttributes.sf_realtime_transcription_status = rawCallAttributes.sf_realtime_transcription_status;
}
}
return JSON.stringify(callAttributes);
}
// UPDATED: Export the two new functions (transformCTR, getCallAttributes)
module.exports = {
generateJWT,
getAccessToken,
formatObjectApiName,
getSObjectFieldValuesFromConnectLambdaParams,
transformCTR,
getCallAttributes
};
In InvokeSalesforceRestApiFunction, add the AmazonS3FullAccess policy to the role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:*",
"s3-object-lambda:*"
],
"Resource": "*"
}
]
}
Next, publish a new version of the Lambda functions. Make sure that you update this code in your Lambda functions to ensure compatibility after the contact center update.
Step 6: Call the New Method on the InvokeSalesforceRestApiFunction Lambda
Call this new method in InvokeSalesforceRestApiFunction by specifying a methodName of retryCTRSync and a soql attribute of SELECT Id, VendorCallKey FROM VoiceCall WHERE CustomerHoldDuration = null. You can schedule this call to run periodically. See Schedule AWS Lambda Functions Using CloudWatch Events in the Amazon documentation.
Test This Example
To test this example:
- Dial your phone number in order to create a Voice Call record. After the call is answered, hang up.
- In your org, find the newly created Voice Call record and remove some CTR fields, including CustomerHoldDuration.
- Call InvokeSalesforceRestApiFunction with the information specified
in the
final
step (step 6) of this example. However, instead of
scheduling the function to run, you can programmatically call the Lambda
from the console as shown in Amazon’s documentation (Create a Lambda function with the
console). Use a payload similar to
this:
{ "Details": { "Parameters": { "methodName": "retryCTRSync", "soql": "SELECT Id, VendorCallKey FROM VoiceCall WHERE CustomerHoldDuration = null" } } }
- Check that the Voice Call record now has the CTR fields filled in.