Saturday, March 2, 2019

Event Sourcing with Redis Streams


Summary

Streams were an addition to the Redis 5.0 release.  Redis streams are roughly analogous to a log file: an append-only data structure.  Redis streams also have some producer/consumer capabilities that are roughly analogous to Kafka streams, with some important differences.  A full explanation of Redis streams here.

Event sourcing is one of those old topics that has been brought back to life again in a new context.  The new context is state persistence and messaging for microservice architectures.  A full explanation of event sourcing here.

This post is about my adventures at implementing event sourcing via Redis streams.  The example is simple and contrived - an account microservice that allows deposits and withdrawals.  I made a rough attempt at implementing this in a 'domain-driven design' model, but I didn't fully adhere to that model as I didn't care for the level of abstraction necessary.

Even though the scenario is simple, my overall impression of event sourcing is - it's hard.  It's hard to think about code in an event-driven manner and it's hard to implement it correctly in a distributed architecture.

Overall Architecture

Diagram below of the high-level architecture:  REST-based microservice that leverages Redis for the event store and MongoDB for event data aggregation.

Service Architecture

Microserver application arch below.  I implemented this with a Node.js HTTP server for the REST routes and an Account Service class that has an event store client and an array of account aggregates.

Projection Architecture

Architecture for the aggregating data from the event store below.  Again, Node.js implementation with a Redis client that realizes an event store functionality and a MongoDB client for aggregating data from events.

Event Store Architecture

A rough outline of what the event store implementation looks like.  I use Redis objects, in particular, the stream object to realize event sourcing functionality such as fetching an event, publishing an event, subscribing for events, etc.

Code Snippets

Creating an Account - accountService.js

The code below leverages a Redis Set object to ensure unique account ID's.  A JSON object is then created with the corresponding 'create' event and then 'published' to Redis.

 create(id) {
  return this._client.addId(id, 'accountId')
  .then(isUnique => {
   logger.debug(`AccountService.create - id:${id} - isUnique:${isUnique}`);
   if (isUnique) {
    const newEvent = {'id' : id, 'version': 0, 'type': 'create'};
    return this._client.publish('accountStream', newEvent);
   }
   else {
    return new Promise((resolve, reject) => {
     resolve(null);
    });
   }
  })
  .then (results => {
   if (results && results.length === 2) {  //results is an array.  first item is the new version number of the aggregate, 
             //second is the timestamp of the create event that was published
    logger.debug(`AccountService.create - id:${id} - results:${results}`);
    const version = results[0];
    const timestamp = results[1];
    const account = new Account(id, version, timestamp);
    this._accounts[id] = account;  //add the new account to the cache
    return {'id' : id};
   }
   else {
    throw new Error('Attempting to create an account id that already exists');
   }
  })
  .catch(err => {
   logger.error(`AccountService.create - id:${id} - ${err}`);
   throw err;
  });
 }

Making a deposit to an Account - accountService.js

The code below attempts to load the account from cache or replay of events if not in cache.  Business logic for a deposit is implemented in the account aggregate (account.js).  If the aggregate allows the deposit, then an event is published.  If the publishing of the event fails (concurrency conflict), the deposit is rolled back and an error is thrown.

 deposit(id, amount) {
  let account;
  
  return this._loadAccount(id) //attempt to load the account from cache and/or rehydrate from events
  .then(result => {
   account = result;
   account.deposit(amount);
   const newEvent = {'id' : id, 'version' : account.version, 'type': 'deposit', 'amount': amount};
   return this._client.publish('accountStream', newEvent);
  })
  .then(results => {
   logger.debug(`AccountService.deposit - id:${id}, amount:${amount} - results:${results}`);
   if (results) {
    account.version = results[0];
    account.timestamp = results[1];
    this._accounts[id] = account; //update the account cache
    return {'id': id, 'amount': amount};
   }
   else {
    account.withdraw(amount); //rolling back aggregate due to unsuccessful publishing of deposit event
    return null;
   }
  })
  .catch(err => {
   logger.error(`PlayerService.deposit - id:${id}, amount:${amount} - ${err}`);
   throw err;
  });
 }

Publishing an event - eventStoreClient.js

The code below implements concurrency control to the event store with Redis' 'watch' method.  Only one process will be permitted to publish an event with a given 'version' number.

 publish(streamName, event) { 
  logger.debug(`EventStoreClient.publish`);
  this._client.watch(event.id); //watch the id (account)
  return this._getAsync(event.id)  //fetch the current version from a Redis key with that ID 
  .then(result => { 
   if (!result || parseInt(result) === parseInt(event.version)) {  //key doesn't exist or versions match
    event.version += 1;  //increment version number prior to publishing the event
    logger.debug(`EventStoreClient.publish - streamName:${streamName}, event:${JSON.stringify(event)}\
     - result:${result}`);
    return new Promise((resolve, reject) => {
     this._client.multi()  //atomic transaction that increments the version and adds event to stream
     .incr(event.id)
     .xadd(streamName, '*', 'event', JSON.stringify(event))
     .exec((err, replies) => {
      if (err) {
       reject(err);
      }
      else {
       resolve(replies);
      }
     });
    });
   }
   else {  //covers the scenario where a concurrent access causes a mismatch with event version numbers
     //return null and then it's up to the client to make another publish attempt
    return new Promise((resolve, reject) => {
     resolve(null);
    });
   }
  })
  .catch(err => {
   logger.error(`EventStoreClient.publish - streamName:${streamName}, event:${event} - ${err}`);
   throw err;
  });
 }

Subscribing for events - eventStoreClient.js

The Redis streams implementation doesn't provide standard pub/sub functionality.  Subscriber behavior can be emulated though using Node.js event emitters coupled with Redis consumer groups.  A given consumer group is read below periodically via setInterval.  If new events are present, they're emitted via the emitter.  The 'subscriber' would implement the corresponding Node event handler for the emitter returned by this function.  This is precisely how the 'projector' is implemented for aggregating event data to a MongoDB database.

 subscribe(streamName, consumerName) { 
  let emitter;
  let groupName = streamName + 'Group';
  logger.debug(`EventStoreClient.subscribe - streamName:${streamName}, groupName:${groupName}, consumerName:${consumerName}`);
  
  if (this._emitters[streamName] && this._emitters[streamName][groupName]) {
   emitter = this._emitters[streamName][groupName];
  }
  else {
   this._client.xgroup('CREATE', streamName, groupName, '0', (err) => {});  //attempt to create Redis group
   emitter = new events.EventEmitter();
   let obj = setInterval(() => {
    this._readGroup(streamName, groupName, consumerName)
    .then(eventList => {
     if (eventList.length > 0) {
      emitter.emit('event', eventList);
     }
    })
    .catch(err => {
     logger.error(`EventStoreClient.subscribe - streamName:${streamName}, groupName:${groupName},\
     consumerName:${consumerName} - ${err}`);
     throw err;     
    });
   }, this._readInterval);
   if (!this._emitters[streamName]) {
    this._emitters[streamName] = {};
   }
   this._emitters[streamName][groupName] = emitter;
   this._intervals.push(obj); 
  }
  
  return emitter;
 }

Sample Results

Below the state of a Redis instance after the following actions:  Create account, Deposit $100, Withdraw $100
127.0.0.1:6379> xrange accountStream - +
1) 1) "1551312621884-0"
   2) 1) "event"
      2) "{\"id\":\"JohnDoe\",\"version\":1,\"type\":\"create\"}"
2) 1) "1551312827949-0"
   2) 1) "event"
      2) "{\"id\":\"JohnDoe\",\"version\":2,\"type\":\"deposit\",\"amount\":100}"
3) 1) "1551312847014-0"
   2) 1) "event"
      2) "{\"id\":\"JohnDoe\",\"version\":3,\"type\":\"withdraw\",\"amount\":100}"
Below is the corresponding state of a MongoDB collection being used for event data aggregation.
> db.accountCollection.find()
{ "_id" : "JohnDoe", "funds" : 0, "timestamps" : [ "1551312621884-0", "1551312827949-0", "1551312847014-0" ] }

Source

Full source w/comments here: https://github.com/joeywhelan/redisStreamEventStore

Copyright ©1993-2024 Joey E Whelan, All rights reserved.

NICE inContact WorkItem Routing


Summary

I'll demonstrate the use of the NICE inContact Work Item API in this post.  That API provides the ability to bring non-native tasks into the routing engine.  The 'workitem' to be routed will be email from Google Gmail.  I'll cover the use of the Gmail API in addition to WorkItem.

This is a contrived example with, at best, demo-grade code.

Overall Architecture

Below is a depiction of the overall set up of this exercise.  Emails are pulled from Gmail via API, submitted to InContact's routing engine via API, then delivered to an agent target via a routing a strategy.


Application Architecture

Below is the application layout.  

Routing Logic

Picture of the routing strategy used for this demo.  This a very simple strategy that pulls a work item from queue and then pops a web page with the email contents to an agent.


Code Snippets


Main Procedure

Code below performs the OAuth2 handshake with Google, gets a list of messages currently in the Inbox, then packages up an array of Promises each of which pulls message contents from Gmail and submit them to the Work Item API.
function processMessage(id) {
 return gmail.getMessage(id)
 .then(msg => {
  return workItem.sendEmail(msg.id, msg.from, msg.payload);
 })
 .then(contactId => {
  return {msgId : id, contactId : contactId};
 });
}

gmail.authorize()
.then(_ => {
 return gmail.getMessageList();
})
.then((msgs) => {
 let p = [];
 msgs.forEach(msg => {
  p.push(processMessage(msg.id));
 });
 return Promise.all(p);
})
.then(results => {
 console.log(results);
})
.catch((err) => {
 console.log(err);
});

Gmail GetMessageList and GetMessage

getMessageList() pulls a list of Gmail IDs currently with the INBOX label.  getMessage() pulls the content of a message for a given Gmail ID.

 getMessageList() {
  console.log('getMessageList()');
  const auth = this.oAuth2Client;
  const gmail = google.gmail({version: 'v1', auth});
  return new Promise((resolve, reject) => {
   gmail.users.messages.list({userId: 'me', labelIds: ['INBOX']}, (err, res) => {
    if (err) {
     reject(err);
    }
    resolve(res);
   });
  })
  .then((res) => {
   return res.data.messages;
  })
  .catch((err) => {
   console.error('getMessageList() - ' + err.message);
   throw err;
  });
 }


 getMessage(id) {
  console.log('getMessage() - id: ' + id);
  const auth = this.oAuth2Client;
  const gmail = google.gmail({version: 'v1', auth});
  return new Promise((resolve, reject) => {
   gmail.users.messages.get({userId: 'me', 'id': id}, (err, res) => {
    if (err) {
     reject(err);
    }
    resolve(res);
   });
  })
  .then((res) => {
   const id = res.data.id;
   const arr = (res.data.payload.headers.find(o => o.name === 'From')).value
   .match(/([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)/gi);
   let from;
   if (arr) {
    from = arr[0];
   }
   else {
    from = '';
   }
   let payload = '';
   if (res.data.payload.body.data) {
    payload = atob(res.data.payload.body.data);
   }
   return {id: id, from: from, payload: payload};
  })
  .catch((err) => {
   console.error('getMessage() - ' + err.message);
   throw err;
  });
 }


WorkItem API Call

This consists of a simple POST with the workItem params in the body.

 postWorkItem(workItemURL, token, id, from, payload, type) {
  console.log('postWorkItem() - url: ' + workItemURL + ' from: ' + from);
  const body = {
    'pointOfContact': this.poc,
    'workItemId': id,
    'workItemPayload': payload,
    'workItemType': type,
    'from': from
  };
 
  return fetch(workItemURL, {
   method: 'POST',
   body: JSON.stringify(body),
   headers: {
    'Content-Type' : 'application/json', 
    'Authorization' : 'bearer ' + token
   },
   cache: 'no-store',
   mode: 'cors'
  })
  .then(response => {
   if (response.ok) {
    return response.json();
   }
   else {
    const msg = 'response status: ' + response.status;
    throw new Error(msg);
   }
  })
  .then(json => {
    return json.contactId;
  })
  .catch(err => {
   console.error('postWorkItem() - ' + err.message);
   throw err;
  });
 }

Results


Source

Full source w/comments here: https://github.com/joeywhelan/workitem

Copyright ©1993-2024 Joey E Whelan, All rights reserved.

Sunday, February 3, 2019

InContact Custom Report API


Summary

This post will walk through the steps necessary to generate a custom report job on InContact's platform via API.  I'll show examples in Node.js (asynchronous) and Python (synchronous).

Overview of Steps


Get Token

Node.js

 getToken() {
  console.log('getToken()');
  const url = 'https://api.incontact.com/InContactAuthorizationServer/Token';
  const body = {
    'grant_type' : 'password',
    'username' : this.username,
    'password' : this.password
  };
  return fetch(url, {
   method: 'POST',
   body: JSON.stringify(body),
   headers: {
    'Content-Type' : 'application/json', 
    'Authorization' : 'basic ' + this.authCode
   },
   cache: 'no-store',
      mode: 'cors'
  })
  .then(response => {
   if (response.ok) {
    return response.json();
   }
   else {
    const msg = 'response status: ' + response.status;
    throw new Error(msg);
   } 
  })
  .then(json => {
   if (json && json.access_token && json.resource_server_base_uri) {
    return json;
   }
   else {
    const msg = 'missing token and/or uri';
    throw new Error(msg);
   }
  })
  .catch(err => {
   console.error('getToken() - ' + err.message);
   throw err;
  });
 }

Python

    def getToken(self):
        print('getToken()')
        url = 'https://api.incontact.com/InContactAuthorizationServer/Token'
        header = {'Authorization' : b'basic ' + self.authCode, 'Content-Type': 'application/json'}
        body =  {'grant_type' : 'password', 'username' : self.username, 'password' : self.password}
        resp = requests.post(url, headers=header, json=body)
        resp.raise_for_status()
        return resp.json()

Start Report Job

Node.js

 startReportJob(reportId, reportURL, token) {
  const url = reportURL + reportId;
  console.log('startReportJob() - url: ' + url);
  const body = {
    'fileType': 'CSV',
    'includeHeaders': 'true',
    'appendDate': 'true',
    'deleteAfter': '7',
    'overwrite': 'true'
  };
  
  return fetch(url, {
   method: 'POST',
   body: JSON.stringify(body),
   headers: {
    'Content-Type' : 'application/json', 
    'Authorization' : 'bearer ' + token
   },
   cache: 'no-store',
   mode: 'cors'
  })
  .then(response => {
   if (response.ok) {
    return response.json();
   }
   else {
    const msg = 'response status: ' + response.status;
    throw new Error(msg);
   }
  })
  .then(json => {
    return json.jobId;
  })
  .catch(err => {
   console.error('startReportJob() - ' + err.message);
   throw err;
  });
 }

Python

    def startReportJob(self, reportId, reportURL, token):
        url = reportURL + reportId
        print('startReportJob() - url: ' + url);
        body = {
                'fileType': 'CSV',
                'includeHeaders': 'true',
                'appendDate': 'true',
                'deleteAfter': '7',
                'overwrite': 'true'
        }
        header = { 'Content-Type' : 'application/json', 'Authorization' : 'bearer ' + token}
        resp = requests.post(url, headers=header, json=body)
        resp.raise_for_status()
        return resp.json()['jobId']

Get File URL

Node.js

 getFileURL(jobId, reportURL, token, numTries=10) {
  console.log('getFileURL() - jobId: ' + jobId + ' numTries: ' + numTries);
  const that = this;
  const url = reportURL + jobId;
  
  return fetch(url, {
   method: 'GET',
   headers: {
    'Content-Type' : 'application/x-www-form-urlencoded', 
    'Authorization' : 'bearer ' + token
   },
   cache: 'no-store',
   mode: 'cors'
  })
  .then(response => {
   if (response.ok) {
    return response.json();
   }
   else {
    const msg = 'response status: ' + response.status;
    throw new Error(msg);
   }
  })
  .then(json => {
   if (json.jobResult.resultFileURL) {
    return json.jobResult.resultFileURL;
   }
   else {
    if (numTries > 0) {  //loop (recursive) up to the numTries parameter
     return new Promise((resolve, reject) => {
      setTimeout(() => { 
       resolve(that.getFileURL(jobId, reportURL, token, numTries-1));
      }, 60000);  //retry once per minute
     });
    }
    else {
     throw new Error('Maximum retries reached');
    } 
   }
  })
  .catch(err => {
   console.error('getFileURL() - ' + err.message);
   throw err;
  });
 }

Python

    def getFileURL(self, jobId, reportURL, token):
        url = reportURL + jobId
        header = { 'Content-Type' : 'application/x-www-form-urlencoded', 'Authorization' : 'bearer ' + token }
        resp = requests.get(url, headers=header)
        fileURL = resp.json()['jobResult']['resultFileURL']
        numTries = 10
        
        while (not fileURL and numTries > 0):
            print('getFileURL() - jobId: ' + jobId + ' numTries: ' + str(numTries))
            time.sleep(60)
            resp = requests.get(url, headers=header)
            fileURL = resp.json()['jobResult']['resultFileURL']
            numTries -= 1
        
        return fileURL

Download Report

Node.js

 
 downloadReport(url, token) {
  console.log('downLoadReport() - url: ' + url);
  
  return fetch(url, {
   method: 'GET',
   headers: {'Authorization' : 'bearer ' + token},
   cache: 'no-store',
   mode: 'cors'
  })
  .then(response => {
   if (response.ok) {
    return response.json();
   }
   else {
    const msg = 'response status: ' + response.status;
    throw new Error(msg);
   }
  })
  .then(json => {
    return json.files.file;
  })
  .catch(err => {
   console.error('downloadReport() - ' + err.message);
   throw err;
  })

Python

    def downloadReport(self, url, token):
        print('downLoadReport() - url: ' + url)
        header = { 'Content-Type' : 'application/x-www-form-urlencoded', 'Authorization' : 'bearer ' + token }
        resp = requests.get(url, headers=header)
        return resp.json()['files']['file']   

Output

Node.js

getReport() - reportId: 4477
getToken()
startReportJob() - url: https://api-c7.incontact.com/inContactAPI/services/v13.0/report-jobs/4477
getFileURL() - jobId: 825795 numTries: 10
getFileURL() - jobId: 825795 numTries: 9
getFileURL() - jobId: 825795 numTries: 8
getFileURL() - jobId: 825795 numTries: 7
downLoadReport() - url: https://api-C7.incontact.com/inContactAPI/services/V15.0/files?fileName=CustomReports%5cApiReports%5cService+Levels_20190203T054855.csv
Job Complete

Python

getReport() - reportId: 4477
getToken()
startReportJob() - url: https://api-c7.incontact.com/inContactAPI/services/v13.0/report-jobs/4477
getFileURL() - jobId: 825797 numTries: 10
getFileURL() - jobId: 825797 numTries: 9
getFileURL() - jobId: 825797 numTries: 8
downLoadReport() - url: https://api-C7.incontact.com/inContactAPI/services/V15.0/files?fileName=CustomReports%5cApiReports%5cService+Levels_20190203T055156.csv
Job Complete

Source

Full source w/comments - https://github.com/joeywhelan/reportdemo

Copyright ©1993-2024 Joey E Whelan, All rights reserved.

Friday, January 4, 2019

MongoDB - E11000 duplicate key error


Summary

In this post, I discuss the symptoms and workaround for an atomicity bug in MongoDB.  This error manifests itself when performing collection updates from multiple threads, with upsert:true, on a unique key.  The bug has been open since 2014 and still unresolved.  https://jira.mongodb.org/browse/SERVER-14322

Bug Set Up

/*jshint esversion: 6 */

'use strict';
'use esversion 6';

const MongoClient = require('mongodb').MongoClient;
const CONNECTION_URL = 'mongodb://admin:mongo@localhost:27017';
const DB_NAME = 'testDB';
const COLLECTION = 'testCollection';

let connection;
let promises = [];

function update(i, db,  query, value, upsrt) {
 return db.collection(COLLECTION).updateOne(query, value, {'upsert' : upsrt})
 .then((result) => {
  return new Promise((resolve, reject) => { resolve('i: ' + i + ' time: ' + new Date().getTime());});
 })
 .catch(err => {
  throw err;
 });
}

MongoClient.connect(CONNECTION_URL, {useNewUrlParser : true})
.then((result) => {
 connection = result;
 const db = connection.db(DB_NAME);
 
 for (let i=0; i<5 .then="" :="" addtoset="" db="" funds="" i="" id="" indices="" johndoe="" let="" p="" promise.all="" promises.push="" promises="" query="" results="" return="" set="" true="" value=""> {
 results.forEach((result) => {
  console.log('result - ' + result);
 });
})
.catch(err => {
 console.log(err); 
})
.finally(() => {
 console.log('closing db connection');
 connection.close();
});

Explanation

The Node.js code above creates an array of promises, each of which attempts to perform a db update with upsert equal to 'true'.  The promises sent thru Promise.all to execute them all as a batch.

Results

The duplicate key error is generated, as expected.
{ MongoError: E11000 duplicate key error collection: testDB.testCollection index: _id_ dup key: { : "johnDoe" }
    at Function.create (/nas/archive/dev/workspace/blackjack/node_modules/mongodb-core/lib/error.js:43:12)
    at toError (/nas/archive/dev/workspace/blackjack/node_modules/mongodb/lib/utils.js:149:22)
    at coll.s.topology.update (/nas/archive/dev/workspace/blackjack/node_modules/mongodb/lib/operations/collection_ops.js:1399:39)
    at /nas/archive/dev/workspace/blackjack/node_modules/mongodb-core/lib/connection/pool.js:532:18
    at process.internalTickCallback (internal/process/next_tick.js:70:11)
  driver: true,
  name: 'MongoError',
  index: 0,
  code: 11000,
  errmsg:
   'E11000 duplicate key error collection: testDB.testCollection index: _id_ dup key: { : "johnDoe" }',
  [Symbol(mongoErrorContextSymbol)]: {} }
closing db connection
Below is the output of a read of this collection.  One update (#4 index) completed before the error was thrown and halted the execution.
> db.testCollection.find({});
{ "_id" : "johnDoe", "funds" : 400, "indices" : [ 4 ] }

Workaround

function updateWithRetry(i, db,  query, value, upsrt) {
 return db.collection(COLLECTION).updateOne(query, value, {'upsert' : upsrt})
 .then((result) => {
  return new Promise((resolve, reject) => { resolve('i: ' + i + ' time: ' + new Date().getTime());});
 })
 .catch(err => {
  if (err.code === 11000){
   console.log('i: ' + i + ' 11000 error');
   return updateWithRetry(i, db, query, value, false);
  }
  else {
   throw err;
  }
 });
}

Explanation

In this revised update block, I added a check for the 11000 error code.  If that's, in fact, the source of the error, I retry the update with a recursive call.

Results

i: 0 11000 error
i: 2 11000 error
i: 3 11000 error
i: 4 11000 error
result - i: 0 time: 1546649403149
result - i: 1 time: 1546649403146
result - i: 2 time: 1546649403149
result - i: 3 time: 1546649403149
result - i: 4 time: 1546649403149
closing db connection
> db.testCollection.find({});
{ "_id" : "johnDoe", "funds" : 400, "indices" : [ 1, 0, 2, 3, 4 ] }

Source


Copyright ©1993-2024 Joey E Whelan, All rights reserved.

Sunday, October 14, 2018

Thoughts on SOWs

Summary

This post is a departure from the normal technical content of this blog.  I'm going to share my thoughts on the topic of Statement of Work (SOW) development in the domain of IT services.  The content of this post is strictly my opinions as I'm not an attorney nor have any legal training whatsoever.  Those opinions are based on ~20 years in Professional Services and active in the writing and implementing of SOWs for technology services.  In many cases, the lessons described here are written in blood/tears from mistakes I and others have made in the past.  

SOW Components

A SOW is a contract, pure and simple.  It's the sort of contract that is the standard for describing services in the technology space.  Below are the typical sections in a SOW.

Header with Parties

This is generally a boilerplate section with a definition of the parties involved in the SOW.  There are typically two:  
  • Service Provider (SP):  This is the organization delivering the services.
  • Customer:  This is the recipient of those professional services.
Lines can be blurred in some instances.  Example:  A SP could be both a provider and recipient in an engagement if they are subcontracting some or all of the services being delivered to a Customer under a SOW.

Scope

The 'Scope' is (or should be, more on that later) the main content of the SOW.  This is where the tasks and deliverables of the included services are described.  This area is the domain of SP SOW author and typically gets the most attention from both the SP and Customer.  Rightfully so, as this area dictates what the Customer will actually receive for their money and what the SP is beholden to deliver.

Terms, Conditions, and Assumptions

Typically a section of the SOW is carved out for boilerplate legal language and/or clarifications to what is/is not included in the SOW scope.  This is usually a list of bullets.    The SOW author may put some content in this area, but this is usually the domain of both sides' Legal/Contracting organizations.

Pricing

This section covers just what you'd expect - the cost of the services described in the Scope.  The structure of the content here is dependent on the SOW design pattern being employed.  Those models are described next.

SOW Design Patterns

In my opinion, there are only two SOW models:  Fixed-bid, Time and Materials.  I'll discuss another model later that I consider an anti-pattern.

Fixed-Bid

This model can be employed when both sides (SP, Customer) clearly understand the services to be delivered.  This type of SOW is harder for the author to construct as more work has to be put into describing the scope.  It's also typically more difficult for the SP to execute profitably as the cost in fixed-bid engagements is just that - fixed.  Costs remain static if the corresponding scope also remains static.  The SP charges exactly what the Pricing section dictates - whether it took 1 or 1 million hours to deliver the tasks + deliverables of the Scope.  

All that being said, this is the SOW model that most Customers typically feel most comfortable with.  They have a budget for a project and want an assurance that they'll get the project completed in that budget.  Fixed-bid can provide that assurance - if requirements remain fixed.

Pricing for this model is best structured around billing milestones tied to a tangible activity or deliverable.  Each milestone represents a percentage of the total services cost of the SOW.  Milestones must be well-bounded as to what 'done' means for that milestone as billing is triggered when 'done' is achieved.  

Example milestone:  Acceptance of Design Document.  The 'Design Document' and the concept of 'Acceptance' must both be rigorously described in the Scope section of the SOW such that both parties agree to what completion of that milestone entails.

Time and Materials (T&M)

This model is typically employed when it is not possible to clearly define the scope of the given project.  That can be for a variety of reasons:  scope simply isn't known at the time of SOW development, requirements are fluid, or - the Customer simply wants access to resources with certain skill sets, i.e., staff augmentation, without specific deliverables.    This is in contrast to a project-based engagement.

As the name suggests, the Customer pays for exactly the amount of services delivered by the SP - typically, metered by the hour.  The SOW will state the number of hours included in the price and an hourly rate.  A good T&M SOW practice is to include a 'circuit-breaker' clause that states the Customer will be notified when the expended hours have reached some critical mass.  Example:  20% remaining.  The Customer can then decide whether they want to increase funding to extend the SOW.  In any case, the services end when the funded hours are expended regardless of the state of the project.

A really bad practice in T&M is to state that 'deliverables' are included (bad from the SP perspective, but probably considered fabulous from the Customer side).  Those two concepts are antithetical.  A 'deliverable' implies something that's guaranteed to be produced under the SOW.  The duration of a T&M SOW is by definition limited by the funded hours.  Scenario:  A 'deliverable' is incomplete but the funded hours have all been expended.  Now what?  

An anti-pattern variant of T&M is 'Capped T&M'.  This non-sensical model is an attempt at a hybrid of T&M and Fixed-bid:  the hours are capped at a level but the Customer only pays for the actual amount expended.  The real mess comes when the SOW author includes deliverables.  The Customer gets the fabulous deal of guaranteed 'deliverables' but only pays for actual hours expended up to a fixed maximum.  The SP gets the short end of that stick.  They didn't understand their scope well enough for a fixed-bid SOW but now have to deliver at what is, in essence, a fixed cost.  Net, this is an imbalanced situation.

Summary graphic below.


SOW Authors

I'm going to speak to this from the SP perspective as they are typically the party that generates the SOW.  It's their services and the SOW represents their quote for those services.  There are occasions when the Customer will generate the first draft of a SOW but those seem to be less common in my experience.

On the SP side, SOW development is almost always a team effort.  There is a main author of the scope content and then various other parties that provide review and/or auxiliary content.  Those other parties are invariably Contracting/Sourcing, Legal and the management of the Services arm that will be responsible to implement the tasks/deliverables of the SOW.

As far as that 'main author' - who should that be?  In my experience, the best results come from individuals that have real-world, hands-on experience in the technology.  This is almost always people that were/are in the Professional Services practice or have independently taken an active role in keeping themselves technically relevant.  It's these subject matter experts (SMEs) that are best suited to follow the sale from the beginning with the SP Sales team.  They hear the Customer explain the requirements first hand and understand any constraints that the Customer has expressed during the sales cycle.

I've seen a number of SPs that do not utilize SMEs for SOW development.  Instead, they'll use weak/generic scope statements or create a separate SOW group altogether that is detached from the Sales process.  Below is a listing of the issues I've seen over and over again with this model:
  • Authors that have no historical context on the engagement.  They weren't involved in the sales cycle so they didn't hear all the conversations with the Customer.  They come in cold to the sale.  As such, it's almost impossible for them to write a coherent fixed-bid SOW.  
  • Authors that have little to no technical or operational background.  They simply can't go to the level of detail necessary to capture the scope because they don't have a firm understanding of those details.
  • Authors that have too much technical background (and no sales background) and produce SOWs that go off the deep-end in technical content.  These SOWs wind up looking like technical design documents instead of a contract that a business leader is going to have to decipher and agree with prior to signing.
In most cases where the SP doesn't use their SE for SOW development, that SP is only comfortable with T&M engagements.  That's for good reason as their risks skyrocket in fixed-bid SOW's developed on incomplete information.

SOW Do's/Don't

  • #1 - Create a balanced first draft of the SOW and strive to keep it that way.  By 'balanced', I mean fair to both sides: Customer and SP.  Imbalanced SOWs just lead to protracted negotiations and in some cases - no sale.
  • Do write detailed scope content.
  • Don't write scope content that resembles a technical design document.
  • Don't put deliverables in T&M SOWs.
  • Don't put labor hour breakouts in Fixed-bid SOWs.  That's an unnecessary artifact that potentially leads to confusion and protracted negotiations.  By definition, the fixed-bid engagement will be delivered at a static cost.  The hours to do so are irrelevant.  Labor breakouts in T&M are fine and expected.
  • For Fixed-bid SOWs, do tie billing milestones to tangible results.
  • Avoid putting fixed delivery dates in any SOW.  Sometimes this cannot be avoided due to the Customer's requirements.  In those cases, something akin to a formal project plan is going to have to be developed prior.  That means the Services arm will have to be pulled in for a detailed analysis of the requirements and project plan development.  In most cases, that will be a non-billable exercise as it's prior to the SOW being executed.
  • Don't attempt to write an exclusion for every item you can think of that's out of scope.  By definition, anything that's not explicitly listed as in-scope is out of scope.  The universe of 'out of scope' is infinite.  You can't hope to capture infinity.
  • In the same vein, if the SOW has more content around exclusions than it does around scope definition - that SOW is likely of poor quality.  This is a sign that the author does not understand the engagement or technology. 

Copyright ©1993-2024 Joey E Whelan, All rights reserved.


Thursday, May 24, 2018

Google Maps API


Summary

In this post I'll show a very simple use-case of the Maps API.  I'll display a map with markers on a simple web page.

Code

Below is a simple javascript array of markers to be added to the map.  This array is stored in a file named markers.js.

'use strict';
'use esversion 6';
const sites = [ 
  { lat: 39.016683491757995, lng: -106.31219892539934, label: 'Site 1' },
  { lat: 39.01841939481699, lng: -106.31966973052391, label: 'Site 2' },
  { lat: 38.816564618651974, lng: -106.243267861874, label: 'Site 3' },
  { lat: 38.970910463727286, lng: -106.40428097049143, label: 'Site 4' }
];

Simple HTML + javascript code to display the map and markers below.

<!DOCTYPE html>
<html>
<head>
 <meta charset="UTF-8">
    <style>
      #map {
        height: 800px;
        width: 100%;
       }
    </style>
 <title>Fabulous Sites</title>
 <script type="text/javascript" src="markers.js"></script>
</head>
<body>
 <div id="map"></div>
 <script>
  function markMap() {
         const center = {lat: 38.8, lng: -106.24};
         const map = new google.maps.Map(document.getElementById('map'), {
           zoom: 10,
           center: center
         });
         for (let i=0; i < sites.length; i++)
          var marker = new google.maps.Marker({
            position: {lat: sites[i].lat, lng: sites[i].lng},
            label: sites[i].label,
            map: map
          }); 
  }
 </script>
 <script async defer
     src="https://maps.googleapis.com/maps/api/js?key=yourkey&callback=markMap">
    </script>
</body>
</html>

Results


Copyright ©1993-2024 Joey E Whelan, All rights reserved.

Google Sentiment Analytics


Summary

In this post, I'll demonstrate use of a Google Natural Language API - Sentiment.  The scenario will be a caller's interaction with a contact center agent is recorded and then sent through Google Speech to Text followed by Sentiment analysis.

Implementation

The diagram below depicts the overall implementation.  Calls enters an ACD/recording platform that has API access in and out.  Recordings are then sent into the Google API's for processing.



Below is a detailed flow of the interactions between the ACD platform and the Google API's.



Code Snippet

App Server

Simple Node.js server below representing the App Server component.
app.post(properties.path, jsonParser, (request, response) => {
 //send a response back to ACD immediately to release script-side resources
 response.status(200).end();
 
 const contactId = request.body.contactId;
 const fileName = request.body.fileName;
 let audio;
 
 logger.info(`contactId: ${contactId} webserver - fileName:${fileName}`);
 admin.get(contactId, fileName) //Fetch the audio file (Base64-encoded) from ACD
 .then((json) => {
  audio = json.file;
  return sentiment.process(contactId, audio);  //Get transcript and sentiment of audio bytes
 })
 .then((json) => { //Upload the audio, transcript, and sentiment to Google Cloud Storage
  return storage.upload(contactId, Buffer.from(audio, 'base64'), json.transcript, JSON.stringify(json.sentiment));
 })
 .then(() => {
  admin.remove(contactId, fileName); //Delete the audio file from ACD
 })
 .catch((err) => {
  logger.error(`contactId:${contactId} webserver - ${err}`);
 });
});

app.listen(properties.listenPort);
logger.info(`webserver - started on port ${properties.listenPort}`);


Source: https://github.com/joeywhelan/sentiment

Copyright ©1993-2024 Joey E Whelan, All rights reserved.