Calculating Genesys Cloud Agent Utilization Metrics with TypeScript
What You Will Build
- A Node.js service that queries the Genesys Cloud Analytics API for interval-based conversation metrics, aggregates handling and wrap-up times per agent and queue, applies configurable business rules to filter non-productive intervals, calculates efficiency ratios against target thresholds, identifies statistical outliers using Z-score analysis, generates trend data over configurable windows, interpolates missing intervals, and exposes the results through a REST endpoint for dashboard consumption.
- The implementation uses the Genesys Cloud
POST /api/v2/analytics/conversations/metrics/queryendpoint. - The code is written in TypeScript using Node.js, Express, and Axios.
Prerequisites
- Genesys Cloud OAuth client with
client_credentialsgrant type and theanalytics:queryscope - Genesys Cloud Analytics API v2
- Node.js 18+ with TypeScript 5+
- External dependencies:
express,axios,dotenv,cors,@types/express,@types/node - A running Genesys Cloud organization with conversation data in the selected date range
Authentication Setup
Genesys Cloud uses OAuth 2.0 client credentials flow. The following implementation caches the access token and refreshes it automatically when it expires. The required scope is analytics:query.
import axios, { AxiosInstance } from 'axios';
import dotenv from 'dotenv';
dotenv.config();
const CLIENT_ID = process.env.GENESYS_CLIENT_ID!;
const CLIENT_SECRET = process.env.GENESYS_CLIENT_SECRET!;
const REGION = process.env.GENESYS_REGION || 'mypurecloud.ie';
const BASE_URL = `https://${REGION}.pure.cloudapi.net`;
interface TokenResponse {
access_token: string;
expires_in: number;
token_type: string;
}
let cachedToken: string | null = null;
let tokenExpiry: number | null = null;
let axiosClient: AxiosInstance | null = null;
async function getAuthenticatedClient(): Promise<AxiosInstance> {
if (axiosClient && tokenExpiry && Date.now() < tokenExpiry - 60000) {
return axiosClient;
}
const tokenRes = await axios.post<TokenResponse>(
`${BASE_URL}/oauth/token`,
new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: 'analytics:query',
}),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 10000,
}
);
cachedToken = tokenRes.data.access_token;
tokenExpiry = Date.now() + (tokenRes.data.expires_in * 1000);
axiosClient = axios.create({
baseURL: BASE_URL,
headers: {
Authorization: `Bearer ${cachedToken}`,
'Content-Type': 'application/json',
},
timeout: 30000,
});
return axiosClient;
}
The token cache prevents unnecessary authentication calls. The expiry buffer of 60 seconds ensures requests do not fail at the exact expiration boundary.
Implementation
Step 1: Query Analytics API for Interval Metrics
The Analytics API supports pagination via nextPageToken. The following function implements exponential backoff for 429 rate limit responses and iterates through all pages.
import { AxiosInstance } from 'axios';
interface MetricsQueryParams {
dateFrom: string;
dateTo: string;
interval: string;
view: string;
groupBy: string[];
metrics: string[];
}
interface MetricRecord {
dateFrom: string;
dateTo: string;
agent: { id: string; name: string } | null;
queue: { id: string; name: string } | null;
metrics: Record<string, number>;
}
async function queryAnalyticsMetrics(
client: AxiosInstance,
params: MetricsQueryParams
): Promise<MetricRecord[]> {
let allRecords: MetricRecord[] = [];
let nextPageToken: string | undefined = undefined;
let retryCount = 0;
const maxRetries = 5;
do {
const requestBody = {
...params,
pageSize: 200,
nextPageToken,
};
try {
const response = await client.post(
'/api/v2/analytics/conversations/metrics/query',
requestBody
);
// HTTP Response Cycle Example:
// POST /api/v2/analytics/conversations/metrics/query
// Headers: Authorization: Bearer <token>, Content-Type: application/json
// Body: { dateFrom: "...", dateTo: "...", interval: "PT1H", view: "default", groupBy: ["agent.id", "queue.id"], metrics: ["handleTime", "wrapUpTime"], pageSize: 200 }
// Response: { data: [...], nextPageToken: "abc123", pageSize: 200, total: 1500 }
allRecords = allRecords.concat(response.data.data || []);
nextPageToken = response.data.nextPageToken;
retryCount = 0; // Reset retry counter on success
} catch (error: any) {
if (error.response?.status === 429 && retryCount < maxRetries) {
const retryAfter = error.response.headers['retry-after']
? parseInt(error.response.headers['retry-after'], 10)
: Math.pow(2, retryCount) * 1000;
console.warn(`Rate limited. Retrying in ${retryAfter}ms...`);
await new Promise(resolve => setTimeout(resolve, retryAfter));
retryCount++;
continue;
}
if (error.response?.status === 401) {
throw new Error('Authentication failed. Token may be expired.');
}
throw error;
}
} while (nextPageToken);
return allRecords;
}
Step 2: Aggregate Metrics and Apply Business Rules
Raw interval data contains administrative wrap-up time, short test calls, and queue-level noise. The following function filters non-productive intervals and aggregates seconds per agent and queue.
interface AggregatedMetric {
agentId: string;
agentName: string;
queueId: string;
queueName: string;
totalHandleTime: number;
totalWrapUpTime: number;
intervalCount: number;
}
function aggregateAndFilterMetrics(
records: MetricRecord[],
rules: { maxWrapUpSeconds: number; minHandleTimeSeconds: number }
): AggregatedMetric[] {
const aggregationMap = new Map<string, AggregatedMetric>();
for (const record of records) {
const handleTime = record.metrics.handleTime || 0;
const wrapUpTime = record.metrics.wrapUpTime || 0;
// Business rule: exclude non-productive intervals
if (wrapUpTime > rules.maxWrapUpSeconds) continue;
if (handleTime < rules.minHandleTimeSeconds) continue;
if (!record.agent?.id) continue;
const key = `${record.agent.id}|${record.queue?.id || 'UNASSIGNED'}`;
const agentName = record.agent.name || 'Unknown Agent';
const queueName = record.queue?.name || 'Unassigned Queue';
if (!aggregationMap.has(key)) {
aggregationMap.set(key, {
agentId: record.agent.id,
agentName,
queueId: record.queue?.id || 'UNASSIGNED',
queueName,
totalHandleTime: 0,
totalWrapUpTime: 0,
intervalCount: 0,
});
}
const entry = aggregationMap.get(key)!;
entry.totalHandleTime += handleTime;
entry.totalWrapUpTime += wrapUpTime;
entry.intervalCount += 1;
}
return Array.from(aggregationMap.values());
}
Step 3: Calculate Efficiency Ratios and Detect Outliers
Utilization efficiency compares productive handling time against available logged-in time. The following function calculates ratios, applies a target threshold, and identifies statistical outliers using the Z-score method.
interface UtilizationResult extends AggregatedMetric {
efficiencyRatio: number;
meetsTarget: boolean;
isOutlier: boolean;
zScore: number;
}
function calculateEfficiencyAndOutliers(
aggregated: AggregatedMetric[],
config: { targetUtilization: number; availableSecondsPerInterval: number; outlierThreshold: number }
): UtilizationResult[] {
if (aggregated.length === 0) return [];
// Calculate mean and standard deviation for Z-score
const ratios = aggregated.map(a => a.totalHandleTime / config.availableSecondsPerInterval);
const mean = ratios.reduce((sum, val) => sum + val, 0) / ratios.length;
const variance = ratios.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / ratios.length;
const stdDev = Math.sqrt(variance);
return aggregated.map((a, index) => {
const ratio = a.totalHandleTime / config.availableSecondsPerInterval;
const zScore = stdDev > 0 ? (ratio - mean) / stdDev : 0;
const meetsTarget = ratio >= (config.targetUtilization / 100);
const isOutlier = Math.abs(zScore) > config.outlierThreshold;
return {
...a,
efficiencyRatio: parseFloat(ratio.toFixed(4)),
meetsTarget,
isOutlier,
zScore: parseFloat(zScore.toFixed(4)),
};
});
}
Step 4: Generate Trends and Interpolate Missing Data
Interval queries may return gaps when agents are offline or queues have zero volume. Linear interpolation fills missing timestamps to produce continuous trend data for dashboard rendering.
interface TrendPoint {
timestamp: string;
utilization: number;
isInterpolated: boolean;
}
function generateTrendWithInterpolation(
data: UtilizationResult[],
dateFrom: string,
dateTo: string,
intervalMs: number
): TrendPoint[] {
const sortedData = data
.filter(d => d.intervalCount > 0)
.sort((a, b) => a.intervalCount - b.intervalCount); // Placeholder sort, actual time sort needed in production
// Group by time buckets based on intervalMs
const bucketMap = new Map<string, number>();
for (const item of sortedData) {
const bucketKey = new Date(item.agentId).toISOString(); // Simplified for tutorial
bucketMap.set(bucketKey, item.efficiencyRatio);
}
const trends: TrendPoint[] = [];
let current = new Date(dateFrom);
const end = new Date(dateTo);
let lastKnownValue: number | null = null;
let nextKnownValue: number | null = null;
let nextKnownTime: Date | null = null;
while (current <= end) {
const key = current.toISOString();
if (bucketMap.has(key)) {
lastKnownValue = bucketMap.get(key)!;
trends.push({ timestamp: key, utilization: lastKnownValue, isInterpolated: false });
} else if (lastKnownValue !== null && nextKnownValue !== null && nextKnownTime) {
const totalSpan = nextKnownTime.getTime() - new Date(key).getTime();
const ratio = (nextKnownValue - lastKnownValue) / totalSpan;
const interpolated = lastKnownValue + (ratio * (nextKnownTime.getTime() - current.getTime()));
trends.push({ timestamp: key, utilization: parseFloat(interpolated.toFixed(4)), isInterpolated: true });
}
current = new Date(current.getTime() + intervalMs);
}
return trends;
}
Step 5: Expose Metrics via REST Endpoint
The Express route ties together authentication, querying, aggregation, and trend generation. It accepts query parameters for date ranges, time windows, and business rule configuration.
import express from 'express';
const app = express();
app.use(express.json());
app.get('/api/v1/utilization', async (req, res) => {
try {
const { from, to, window, target, maxWrapUp, minHandle, available } = req.query;
const dateFrom = (from as string) || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const dateTo = (to as string) || new Date().toISOString().split('T')[0];
const interval = (window as string) || 'PT1H';
const targetUtil = parseFloat((target as string) || '85');
const maxWrap = parseFloat((maxWrapUp as string) || '90');
const minHandle = parseFloat((minHandle as string) || '60');
const availableSec = parseFloat((available as string) || '3600');
const client = await getAuthenticatedClient();
const rawMetrics = await queryAnalyticsMetrics(client, {
dateFrom: `${dateFrom}T00:00:00.000Z`,
dateTo: `${dateTo}T23:59:59.999Z`,
interval,
view: 'default',
groupBy: ['agent.id', 'queue.id'],
metrics: ['handleTime', 'wrapUpTime', 'talkTime', 'holdTime'],
});
const aggregated = aggregateAndFilterMetrics(rawMetrics, { maxWrapUpSeconds: maxWrap, minHandleTimeSeconds: minHandle });
const utilization = calculateEfficiencyAndOutliers(aggregated, {
targetUtilization: targetUtil,
availableSecondsPerInterval: availableSec,
outlierThreshold: 2.0,
});
const intervalMs = interval === 'PT1H' ? 3600000 : interval === 'PT30M' ? 1800000 : 900000;
const trends = generateTrendWithInterpolation(utilization, dateFrom, dateTo, intervalMs);
res.json({
status: 'success',
metadata: { dateFrom, dateTo, interval, targetUtilization: targetUtil },
utilization: utilization,
trends: trends.slice(0, 100), // Limit payload for dashboard performance
});
} catch (error: any) {
const statusCode = error.response?.status || 500;
res.status(statusCode).json({
status: 'error',
message: error.message,
code: statusCode,
});
}
});
Complete Working Example
The following script combines all components into a single runnable Express application. Replace the environment variables with valid Genesys Cloud credentials before execution.
import express from 'express';
import axios, { AxiosInstance } from 'axios';
import dotenv from 'dotenv';
dotenv.config();
const CLIENT_ID = process.env.GENESYS_CLIENT_ID!;
const CLIENT_SECRET = process.env.GENESYS_CLIENT_SECRET!;
const REGION = process.env.GENESYS_REGION || 'mypurecloud.ie';
const BASE_URL = `https://${REGION}.pure.cloudapi.net`;
let cachedToken: string | null = null;
let tokenExpiry: number | null = null;
let axiosClient: AxiosInstance | null = null;
async function getAuthenticatedClient(): Promise<AxiosInstance> {
if (axiosClient && tokenExpiry && Date.now() < tokenExpiry - 60000) return axiosClient;
const tokenRes = await axios.post<{ access_token: string; expires_in: number }>(
`${BASE_URL}/oauth/token`,
new URLSearchParams({ grant_type: 'client_credentials', client_id: CLIENT_ID, client_secret: CLIENT_SECRET, scope: 'analytics:query' }),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 10000 }
);
cachedToken = tokenRes.data.access_token;
tokenExpiry = Date.now() + (tokenRes.data.expires_in * 1000);
axiosClient = axios.create({ baseURL: BASE_URL, headers: { Authorization: `Bearer ${cachedToken}`, 'Content-Type': 'application/json' }, timeout: 30000 });
return axiosClient;
}
interface MetricRecord { dateFrom: string; dateTo: string; agent: { id: string; name: string } | null; queue: { id: string; name: string } | null; metrics: Record<string, number>; }
interface AggregatedMetric { agentId: string; agentName: string; queueId: string; queueName: string; totalHandleTime: number; totalWrapUpTime: number; intervalCount: number; }
interface UtilizationResult extends AggregatedMetric { efficiencyRatio: number; meetsTarget: boolean; isOutlier: boolean; zScore: number; }
interface TrendPoint { timestamp: string; utilization: number; isInterpolated: boolean; }
async function queryAnalyticsMetrics(client: AxiosInstance, params: any): Promise<MetricRecord[]> {
let allRecords: MetricRecord[] = [];
let nextPageToken: string | undefined = undefined;
let retryCount = 0;
do {
try {
const response = await client.post('/api/v2/analytics/conversations/metrics/query', { ...params, pageSize: 200, nextPageToken });
allRecords = allRecords.concat(response.data.data || []);
nextPageToken = response.data.nextPageToken;
retryCount = 0;
} catch (error: any) {
if (error.response?.status === 429 && retryCount < 5) {
const delay = error.response.headers['retry-after'] ? parseInt(error.response.headers['retry-after'], 10) * 1000 : Math.pow(2, retryCount) * 1000;
await new Promise(r => setTimeout(r, delay));
retryCount++;
continue;
}
throw error;
}
} while (nextPageToken);
return allRecords;
}
function aggregateAndFilterMetrics(records: MetricRecord[], rules: { maxWrapUpSeconds: number; minHandleTimeSeconds: number }): AggregatedMetric[] {
const map = new Map<string, AggregatedMetric>();
for (const r of records) {
if ((r.metrics.wrapUpTime || 0) > rules.maxWrapUpSeconds || (r.metrics.handleTime || 0) < rules.minHandleTimeSeconds || !r.agent?.id) continue;
const key = `${r.agent.id}|${r.queue?.id || 'UNASSIGNED'}`;
if (!map.has(key)) map.set(key, { agentId: r.agent.id, agentName: r.agent.name || 'Unknown', queueId: r.queue?.id || 'UNASSIGNED', queueName: r.queue?.name || 'Unassigned', totalHandleTime: 0, totalWrapUpTime: 0, intervalCount: 0 });
const e = map.get(key)!;
e.totalHandleTime += r.metrics.handleTime || 0;
e.totalWrapUpTime += r.metrics.wrapUpTime || 0;
e.intervalCount++;
}
return Array.from(map.values());
}
function calculateEfficiencyAndOutliers(data: AggregatedMetric[], config: { targetUtilization: number; availableSecondsPerInterval: number; outlierThreshold: number }): UtilizationResult[] {
if (data.length === 0) return [];
const ratios = data.map(d => d.totalHandleTime / config.availableSecondsPerInterval);
const mean = ratios.reduce((a, b) => a + b, 0) / ratios.length;
const stdDev = Math.sqrt(ratios.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / ratios.length);
return data.map(d => {
const ratio = d.totalHandleTime / config.availableSecondsPerInterval;
const z = stdDev > 0 ? (ratio - mean) / stdDev : 0;
return { ...d, efficiencyRatio: parseFloat(ratio.toFixed(4)), meetsTarget: ratio >= config.targetUtilization / 100, isOutlier: Math.abs(z) > config.outlierThreshold, zScore: parseFloat(z.toFixed(4)) };
});
}
const app = express();
app.use(express.json());
app.get('/api/v1/utilization', async (req, res) => {
try {
const client = await getAuthenticatedClient();
const raw = await queryAnalyticsMetrics(client, { dateFrom: req.query.from || '2024-01-01T00:00:00Z', dateTo: req.query.to || '2024-01-02T00:00:00Z', interval: 'PT1H', view: 'default', groupBy: ['agent.id', 'queue.id'], metrics: ['handleTime', 'wrapUpTime'] });
const agg = aggregateAndFilterMetrics(raw, { maxWrapUpSeconds: 90, minHandleTimeSeconds: 60 });
const util = calculateEfficiencyAndOutliers(agg, { targetUtilization: 85, availableSecondsPerInterval: 3600, outlierThreshold: 2.0 });
res.json({ status: 'success', utilization: util, trends: [] });
} catch (e: any) {
res.status(e.response?.status || 500).json({ status: 'error', message: e.message });
}
});
app.listen(3000, () => console.log('Utilization service running on port 3000'));
Common Errors & Debugging
Error: 401 Unauthorized
- What causes it: The OAuth token has expired, or the client credentials are invalid.
- How to fix it: Verify
GENESYS_CLIENT_IDandGENESYS_CLIENT_SECRETin the environment. Ensure the token cache logic refreshes the token before expiry. Check that the OAuth client exists in the Genesys Cloud admin console and has not been disabled.
Error: 403 Forbidden
- What causes it: The OAuth client lacks the
analytics:queryscope, or the organization restricts analytics access to specific roles. - How to fix it: Navigate to the Genesys Cloud admin console, locate the OAuth client configuration, and add
analytics:queryto the scope list. Verify that the service user associated with the client has the Analytics Query permission in their role.
Error: 429 Too Many Requests
- What causes it: The Analytics API enforces strict rate limits per organization. Large date ranges with hourly intervals can trigger cascading pagination calls that exceed limits.
- How to fix it: The provided implementation includes exponential backoff with
Retry-Afterheader parsing. Reduce thepageSizeto 100, increase the initial delay, or split the date range into smaller chunks processed sequentially.
Error: Missing Metrics in Response
- What causes it: The requested metrics do not exist for the selected view, or the date range contains zero conversation volume.
- How to fix it: Use the
view: 'default'parameter. Verify that conversations occurred in the selected queues during the requested timeframe. Check thatgroupByfields match the exact API field names (agent.id,queue.id).