Newer Version Available

This content describes an older version of this product. View Latest

Retry CTR Sync for Voice Call Records

Amazon Connect Call Trace Record (CTR) data is stored automatically in Voice Call records through data synchronization. Sometimes this sync doesn’t occur, and it isn’t always possible to access CTR data in Amazon Connect after the call. Back up CTR data to a separate Amazon S3 bucket, then check for Voice Call records that don’t have CTR data, and then resync the CTR data to your org using the backup data.
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 agent 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

To run this example:
  1. Have your AWS root user or AWS administrator credentials ready.
  2. Be familiar with Amazon S3 buckets. See Amazon’s documentation: Creating and configuring an S3 bucket.
  3. Be familiar with modifying Amazon Connect Lambda functions. See Amazon’s documentation: AWS Lambda Developer Guide.
  4. 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

Create an Amazon S3 bucket on the same instance as your contact center. This bucket must be accessible within the region where your Amazon instance is configured. When creating your S3 bucket, make sure that you set the right access control policies for the bucket so it's not accessible outside of your instance.
  1. Under General Configuration > Bucket Type, select General Purpose.
  2. Under Object Ownership, select ACLs disabled (recommended).
  3. Under Block Public Access settings for this bucket, select Block all public access.
  4. Under Bucket Versioning, select Enable.
  5. Under Default Encryption > Encryption Type, select Server-side encryption with Amazon S3 managed keys (SSE-S3).
  6. 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.

1{
2    "Version": "2008-10-17",
3    "Statement": [
4        {
5            "Sid": "PutObjectAccess",
6            "Effect": "Allow",
7            "Principal": {
8                "AWS": "arn:aws:iam::<your account id>:root"    // REPLACE THIS WITH YOUR ACCOUNT ID
9            },
10            "Action": "s3:*",
11            "Resource": "<your S3 Bucket ARN>"    // REPLACE THIS WITH YOUR S3 BUCKET ARN
12        },
13        {
14            "Effect": "Allow",
15            "Principal": {
16                "Service": "transcribe.amazonaws.com"
17            },
18            "Action": "s3:*",
19            "Resource": "<your S3 Bucket ARN>"     // REPLACE THIS WITH YOUR S3 BUCKET ARN
20        }
21    ]
22}

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.

1const aws = require("aws-sdk");
2const SCVLoggingUtil = require("./SCVLoggingUtil");
3const s3 = new aws.S3();
4
5const lambda = new aws.Lambda();
6const utils = require("./utils");
7
8function updateVoiceCallRecord(voiceCall) {
9  SCVLoggingUtil.info({
10    message: "CTR/updateVoiceCallRecord Request created",
11    context: { contactId: voiceCall.contactId },
12  });
13  const payload = {
14    Details: {
15      Parameters: {
16        methodName: "updateVoiceCall",
17        fieldValues: voiceCall.fields,
18        contactId: voiceCall.contactId,
19      },
20    },
21  };
22
23  const params = {
24    FunctionName: process.env.INVOKE_TELEPHONY_INTEGRATION_API_ARN,
25    Payload: JSON.stringify(payload),
26  };
27
28  return lambda.invoke(params).promise();
29}
30
31function shouldProcessCtr(ctrRecord) {
32  return (
33    ["INBOUND", "OUTBOUND", "TRANSFER", "CALLBACK", "API"].includes(
34      ctrRecord.InitiationMethod
35    ) &&
36    ctrRecord.ContactId &&
37    // if the call is a voicemail, no need to process it because the packager lambda updates the voicecall record
38    !(
39      ctrRecord.Attributes &&
40      ctrRecord.Attributes.vm_flag &&
41      ctrRecord.Recordings
42    )
43  );
44}
45
46exports.handler = async (event) => {
47  const promises = [];
48  SCVLoggingUtil.debug({
49    message: "CTRDataSync event received",
50    context: event,
51  });
52  event.Records.forEach((record) => {
53    const ctr = utils.parseData(record.kinesis.data);
54    if (shouldProcessCtr(ctr)) {
55      const voiceCall = utils.transformCTR(ctr);
56      SCVLoggingUtil.debug({
57        category: "ctrDataSync.handler",
58        message: "Transformed CTR to voice call",
59        context: voiceCall,
60      });
61      const updatePromise = updateVoiceCallRecord(voiceCall);
62
63      promises.push(updatePromise);
64
65      updatePromise.then((response) => {
66        SCVLoggingUtil.info({
67          message: "updateVoiceCallRecord response",
68          context: response,
69        });
70      });
71
72      writeCTRToS3(ctr);
73
74    } else {
75      SCVLoggingUtil.error({
76        message: "Encountered Non supported CTR Events: failing fast",
77        context: {},
78      });
79    }
80  });
81
82  return Promise.all(promises);
83};
84
85
86function writeCTRToS3(ctr) {
87    var params = {
88        Bucket : "<your S3 bucket Name>",   // REPLACE THIS WITH YOUR S3 BUCKET NAME
89        Key: ctr.ContactId,
90        Body: Buffer.from(JSON.stringify(ctr)),
91    }
92
93    s3.putObject(params, function(err, data) {
94        if (err) {
95            console.log("Error uploading CTR to S3: " + JSON.stringify(err));
96        } else {
97            console.log("Successfully uploaded CTR to S3: " + JSON.stringify(data));
98        }
99    });
100}
101
102
103exports.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:

1{
2    "Version": "2012-10-17",
3    "Statement": [
4        {
5            "Sid": "VisualEditor0",
6            "Effect": "Allow",
7            "Action": [
8                "s3:PutObject",
9                "s3:ListBucket",
10                "s3:PutObjectAcl"
11            ],
12            "Resource": "<your S3 Bucket ARN>/*" // REPLACE THIS WITH YOUR S3 BUCKET ARN
13        }
14    ]
15}
Creating a new inline policy for the IAM role is required for the CTRDataSyncFunction to run, but it is not the only required policy. The other required policies come incorporated out of the box, but it’s a good idea to confirm that they’re present. These policies are:

Depending on how your org is configured, out of the box policies can come with additional attributes.

Important

Step 4: Re-Sync Voice Calls That Aren’t Synced

Modify the InvokeSalesforceRestApiFunction Lambda to perform three key actions:

  1. 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.
    1SELECT Id, VendorCallKey FROM VoiceCall WHERE CustomerHoldDuration = null
  2. 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.
  3. Update Voice Call records in the org. Update Voice Call records based on the information returned from the previous step.

This code calls the InvokeTelephonyIntegrationApiFunction Lambda function. You can call it in one of two ways:

This sample code assumes that there’s an environment variable named INVOKE_TELEPHONY_INTEGRATION_API_ARN (option a), but you can hard-code the ARN if you prefer.

Important

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.

1const utils = require("./utils");
2const axiosWrapper = require("./axiosWrapper");
3const SCVLoggingUtil = require("./SCVLoggingUtil");
4
5const aws = require('aws-sdk');
6const lambda = new aws.Lambda();
7const s3 = new aws.S3();
8
9….
10async function searchRecord(sosl) {
11  try {
12    const response = await sendRequest(
13      "get",
14      `/search/?q=${encodeURIComponent(sosl)}`
15    );
16    SCVLoggingUtil.debug({
17      category: "sfRestApi.searchRecord",
18      message: "search Record response",
19      context: response,
20    });
21    if (response.data.searchRecords.length === 0) {
22      return {};
23    }
24    return response.data.searchRecords[0];
25  } catch (e) {
26    return buildError(e);
27  }
28}
29
30// NEW function that performs CTR sync retry
31async function retryCtrSync(soql) {
32    // Query For VoiceCalls that are missing CTR Sync
33    const response = await sendRequest(
34      "get",
35      `/query/?q=${encodeURIComponent(soql)}`
36    );
37    console.log("Querying for VoiceCalls that were not updated");
38    
39    if (response.data.errorType) {
40        console.log("Error querying VoiceCalls: " + response.data.errorMessage);
41    
42        return {
43            success: false,
44            errorMessage: response.data.errorMessage
45        };
46    } else if (response.data.totalSize === 0) {
47        console.log("No VoiceCalls were found that were missing CTR update");
48        return {};
49    } else {
50        console.log("Successfully queried for VoiceCalls");
51        const result = response.data.records;
52        
53        // Fetch CTRs from S3
54        var ctrRecords = await getCtrsFromS3(result);
55        
56        // Update VoiceCalls with the CTRs fetched from S3
57        var results = await updateVoiceCallRecords(ctrRecords);
58        return results;
59    }
60} 
61
62
63// NEW Function to fetch CTRs from S3
64async function getCtrsFromS3(result) {
65    var ctrData = [];
66    for (var i=0; i<result.length; i++) {
67        console.log("Vendor Call Key " + result[i].VendorCallKey);
68        var getParams = {
69            Bucket: '<your S3 bucket Name>',      // your bucket name,
70            Key:  result[i].VendorCallKey // path to the object you're looking for
71        }
72
73        await s3.getObject(getParams, function(err, data1) {
74            if (err) {
75                console.log("ERROR in querying S3 " + err);
76                return err;
77            } else {
78                var objectData = data1.Body.toString('utf-8'); // Use the encoding necessary
79                var ctrObj = JSON.parse(objectData);
80                console.log("CTR found for ContactId: " + ctrObj.ContactId);
81                ctrData.push(ctrObj);
82            }
83        }).promise();
84    }
85    return ctrData;
86}
87
88// NEW function to update VoiceCall Records
89async function updateVoiceCallRecords(ctrRecords) {
90    let promises = [];
91    ctrRecords.forEach((record) => {
92        const ctr = record;
93        
94        if (ctr.ContactId) {
95            const voiceCall = utils.transformCTR(ctr);
96            promises.push(updateVoiceCallRecord(voiceCall));
97        } else {
98            console.log("No ContactId found in CTR");
99        }
100    });
101    
102    return await Promise.all(promises);
103}
104
105// UPDATED: Add environment variable
106function updateVoiceCallRecord(voiceCall) {
107    console.log("Updating VoiceCall");
108    const payload = {
109        Details: {
110            Parameters: {
111                methodName: 'updateVoiceCall',
112                fieldValues: voiceCall.fields,
113                contactId: voiceCall.contactId
114            }
115        }
116    };
117    
118    const params = {
119        // NEW: Create this as an environment variable 
120        //      or hard-code the ARN of the Lambda
121        FunctionName: process.env.INVOKE_TELEPHONY_INTEGRATION_API_ARN,
122  
123        Payload: JSON.stringify(payload)
124    };
125    return lambda.invoke(params).promise();
126}
127
128
129// UPDATED: Include the new function (retryCtrSync)
130module.exports = {
131    createRecord,
132    updateRecord,
133    queryRecord,
134    searchRecord,
135    sendRealtimeAlertEvent,
136    retryCtrSync,
137};

Updates to QueryEngine.js. In-context changes are in bold. Remove any comments in the code before saving.

1const api = require("./sfRestApi");
2const SCVLoggingUtil = require("./SCVLoggingUtil");
3
4function formatQuery(args, queryStr) {
5  let query;
6  Object.keys(args).forEach((key) => {
7    const replacement = `{${key}}`;
8    query = queryStr.replace(replacement, args[key]);
9  });
10  return query;
11}
12
13// invokes the query from sf rest api
14// can take the query as a formatted string of sorts,
15// replacing {key} with its value in the js object
16async function invokeQuery(query, args) {
17  const formattedQuery = formatQuery(args, query);
18  SCVLoggingUtil.debug({
19    message: "invoke query from SfRestApi",
20    context: { payload: formattedQuery },
21  });
22  return api.queryRecord(formattedQuery);
23}
24
25
26// NEW function to that exposes the Retry CTR
27async function retryCTRSync(query, args) {
28    var formattedQuery = formatQuery(args, query);
29    return await api.retryCtrSync(formattedQuery);
30}
31
32
33// UPDATED: Export the new function (retryCtrSync)
34module.exports={
35    invokeQuery,
36    formatQuery,
37    retryCTRSync
38};

Updates to Handler.js. In-context changes are in bold:

1const flatten = require("flat");
2const SCVLoggingUtil = require("./SCVLoggingUtil");
3const api = require("./sfRestApi");
4const queryEngine = require("./queryEngine");
5const utils = require("./utils");
6const SFSPhoneCallFlow = require("./SFSPhoneCallFlow");
7
8// --------------- Events -----------------------
9async function dispatchSearch(sosl) {
10  const searchResult = await api.searchRecord(sosl);
11  return flatten(searchResult);
12}
13
14
15async function dispatch_ctrsync(soql, event){
16    const parameters = event.Details.Parameters;
17    console.log(event)
18    let response;
19    try {
20        const queryResult = await queryEngine.retryCTRSync(soql, parameters);
21        response = {
22            statusCode: 200,
23            result: queryResult
24        }
25    }
26    catch (e) {
27        response = {
28            statusCode: e.response && e.response.status ? e.response.status : 500,
29            result: e
30        }
31    }
32    return flatten(response);
33}
34
35// --------------- Main handler -----------------------;
36    case "SFSPhoneCallFlowQuery": {
37      const res = await SFSPhoneCallFlow.entryPoint(event);
38      result = flatten(res);
39      break;
40    }
41    
42    case 'retryCTRSync': {
43            result = dispatch_ctrsync(soql, event);
44            break;
45    }
46    
47
48    default: {
49      SCVLoggingUtil.warn({
50        message: "Unsupported method",
51        context: { payload: event },
52      });
53      throw new Error(`Unsupported method: ${methodName}`);
54    }
55  }
56
57  if (result.success === false) {
58    throw new Error(result.errorMessage);
59  } else {
60    return result;
61  }
62};
63
64    }

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.

1function getRealtimeAlertEventFieldValuesFromConnectLambdaParams(params) {
2  const fieldValues = {};
3  Object.entries(params).forEach((entry) => {
4    const key = entry[0];
5    if (key !== "methodName") {
6      fieldValues[key] = entry[1];
7    }
8  });
9  return fieldValues;
10}
11
12// COPIED function to transform CTR 
13function transformCTR(ctr) {
14    const voiceCall = {};
15    
16    voiceCall.startTime = ctr.InitiationTimestamp;
17    voiceCall.endTime = ctr.DisconnectTimestamp;
18    voiceCall.parentCallIdentifier = ctr.PreviousContactId;
19
20    if (ctr.Agent) {
21        voiceCall.acceptTime = ctr.Agent.ConnectedToAgentTimestamp;
22        voiceCall.totalHoldDuration = ctr.Agent.CustomerHoldDuration;
23        voiceCall.longestHoldDuration = ctr.Agent.LongestHoldDuration;
24        voiceCall.agentInteractionDuration = ctr.Agent.AgentInteractionDuration;
25        voiceCall.numberOfHolds = ctr.Agent.NumberOfHolds;
26    }
27    
28    if (ctr.Queue) {
29        voiceCall.enqueueTime = ctr.Queue.EnqueueTimestamp;
30        voiceCall.queue = ctr.Queue.Name;
31    }
32    
33    if (ctr.InitiationMethod) {
34        voiceCall.initiationMethod = ctr.InitiationMethod;
35        if (ctr.InitiationMethod === 'OUTBOUND') {
36            if (ctr.SystemEndpoint) {
37                voiceCall.fromNumber = ctr.SystemEndpoint.Address;
38            }
39            if ( ctr.CustomerEndpoint) {
40                voiceCall.toNumber = ctr.CustomerEndpoint.Address;
41            }
42        } else {
43            if (ctr.SystemEndpoint) {
44                voiceCall.toNumber = ctr.SystemEndpoint.Address;
45            }
46            if ( ctr.CustomerEndpoint) {
47                voiceCall.fromNumber = ctr.CustomerEndpoint.Address;
48            }
49        }
50    }
51
52    if (ctr.Recording) {
53        voiceCall.recordingLocation = ctr.Recording.Location;
54    }
55
56    // Check if there are custom contact attributes 
57    if (ctr.Attributes) {
58        var callAttributes = {};
59    
60        // Get contact attributes data into call attributes
61        callAttributes =  getCallAttributes(ctr.Attributes);
62    
63        voiceCall.callAttributes = callAttributes;
64    }
65
66    Object.keys(voiceCall).forEach(function (key) {
67        if (voiceCall[key] === null || voiceCall[key] === undefined) {
68            delete voiceCall[key];
69        }
70    });
71    
72    return {contactId: ctr.ContactId, fields: voiceCall};
73}
74
75// COPIED function to filter call attributes
76/**
77 * Filter call attributes to be included in API payload based on prefix and strip prefix
78 * 
79 * @param {object} rawCallAttributes - Contact flow attributes
80 * 
81 * @return {string} - Stringified contact flow attributes with prefix removed
82 */
83function getCallAttributes(rawCallAttributes) {
84    const prefix = 'sfdc-';
85    const prefixLen = prefix.length;
86    let callAttributes = {};
87
88    for (const key in rawCallAttributes) {
89        if (rawCallAttributes.hasOwnProperty(key) && key.startsWith(prefix)) {
90            callAttributes[key.substring(prefixLen)] = rawCallAttributes[key];
91        }
92        // Set SCV Limits Error if the specific contact attribute is set
93        if (rawCallAttributes.sf_realtime_transcription_status) {
94            callAttributes.sf_realtime_transcription_status = rawCallAttributes.sf_realtime_transcription_status;
95        }
96    }
97
98    return JSON.stringify(callAttributes);
99}
100
101
102// UPDATED: Export the two new functions (transformCTR, getCallAttributes)
103module.exports = {
104    generateJWT,
105    getAccessToken,
106    formatObjectApiName,
107    getSObjectFieldValuesFromConnectLambdaParams,
108    
109    transformCTR,
110    getCallAttributes
111    
112};

In InvokeSalesforceRestApiFunction, add the AmazonS3FullAccess policy to the role:

1{
2    "Version": "2012-10-17",
3    "Statement": [
4        {
5            "Effect": "Allow",
6            "Action": [
7                "s3:*",
8                "s3-object-lambda:*"
9            ],
10            "Resource": "*"
11        }
12    ]
13}

Adding the AmazonS3FullAccess policy to the role is required for the CTRDataSyncFunction to run, but it’s not the only required policy. The other required policies come incorporated out of the box, but it’s a good idea to confirm that they’re present. These policies are:

Depending on how your org is configured, out of the box policies can come with additional attributes.

Important

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:

  1. Dial your phone number in order to create a Voice Call record. After the call is answered, hang up.
  2. In your org, find the newly created Voice Call record and remove some CTR fields, including CustomerHoldDuration.
  3. 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:
    1{
    2  "Details": {
    3    "Parameters": {
    4      "methodName": "retryCTRSync",
    5      "soql": "SELECT Id, VendorCallKey FROM VoiceCall WHERE CustomerHoldDuration = null"
    6    }
    7  }
    8}
  4. Check that the Voice Call record now has the CTR fields filled in.