Getting null values for wrapUpCode in analytics detail queries — known limitation or query error
What You Will Build
- You will build a Python script that queries Genesys Cloud Analytics conversation details and correctly handles
nullwrapUpCodevalues by distinguishing between missing data and actual empty wrap-up codes. - You will use the Genesys Cloud Python SDK (
genesyscloud) to execute an Analytics Detail Query. - You will cover the Python programming language, with references to the underlying REST API behavior.
Prerequisites
- OAuth Client Type: Public or Confidential client.
- Required Scopes:
analytics:detail:readis the primary scope required for querying conversation details.conversation:readmay be needed if you expand related resources. - SDK Version:
genesyscloudPython SDK version 2.3.0 or higher. - Language/Runtime: Python 3.8+.
- Dependencies:
genesyscloudpydantic(included with the SDK)datetime(standard library)
Authentication Setup
The Genesys Cloud Python SDK handles OAuth2 authentication automatically if you provide the client credentials. For production scripts, avoid hardcoding credentials. Use environment variables.
import os
import sys
from genesyscloud import Configuration, ApiClient, AnalyticsApi, ConversationDetailQueryRequest
def get_config():
"""
Initializes the Genesys Cloud API configuration.
Uses environment variables for security.
"""
config = Configuration()
# Genesys Cloud Environment (e.g., us-east-1, eu-west-1)
config.host = os.getenv("GENESYS_CLOUD_REGION", "https://api.mypurecloud.com")
# Client Credentials
config.client_id = os.getenv("GENESYS_CLOUD_CLIENT_ID")
config.client_secret = os.getenv("GENESYS_CLOUD_CLIENT_SECRET")
# Validate credentials
if not config.client_id or not config.client_secret:
raise ValueError("GENESYS_CLOUD_CLIENT_ID and GENESYS_CLOUD_CLIENT_SECRET must be set.")
return config
def get_analytics_api_client(config: Configuration) -> AnalyticsApi:
"""
Returns an initialized AnalyticsApi client.
"""
api_client = ApiClient(config)
analytics_api = AnalyticsApi(api_client)
return analytics_api
Implementation
Step 1: Constructing the Analytics Detail Query
The root cause of unexpected null values often lies in how the query filter is constructed. If you filter for wrapupCode specifically, you may inadvertently exclude conversations where the agent did not select a code. However, if you query broadly and receive null, it indicates the conversation type or state does not support wrap-up codes.
You must define the ConversationDetailQueryRequest with a time range and a filter for conversationType. Voice conversations are the primary type where wrapUpCode is relevant.
from datetime import datetime, timedelta
import pytz
def build_query_request(start_time: datetime, end_time: datetime) -> ConversationDetailQueryRequest:
"""
Builds a query request for voice conversations within a specific time window.
"""
# Convert to UTC ISO 8601 string format required by the API
utc = pytz.UTC
start_str = start_time.astimezone(utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
end_str = end_time.astimezone(utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
query = ConversationDetailQueryRequest(
filter=ConversationDetailQueryRequestFilter(
conversation_types=["voice"]
),
interval_type="fixed",
from_=start_str,
to=end_str
)
return query
Step 2: Executing the Query and Handling Pagination
The post_analytics_conversations_details_query endpoint returns a paginated result. You must iterate through pages to ensure you capture all conversations. The SDK simplifies this, but you must handle the next_page link manually if you want full control, or use the SDK’s built-in pagination helper if available in your version. Below is the explicit loop method which provides better error visibility.
def fetch_conversation_details(analytics_api: AnalyticsApi, query: ConversationDetailQueryRequest):
"""
Fetches all conversation details based on the query, handling pagination.
Returns a list of ConversationDetail objects.
"""
all_details = []
next_page = None
max_retries = 3
retry_count = 0
while True:
try:
if retry_count > 0:
import time
time.sleep(2 ** retry_count) # Exponential backoff
# Execute the query
# The SDK method maps to POST /api/v2/analytics/conversations/details/query
response = analytics_api.post_analytics_conversations_details_query(
body=query,
_preload_content=True
)
# Append current page results
if response.entities:
all_details.extend(response.entities)
# Check for next page
if response.next_page:
next_page = response.next_page
# Update the query with the cursor or page token if required by the SDK version
# In newer SDK versions, the response object handles pagination internally,
# but explicitly checking next_page is safer for older implementations.
query.next_page = next_page
retry_count = 0
else:
break
except Exception as e:
retry_count += 1
print(f"Error fetching page: {e}")
if retry_count >= max_retries:
raise e
return all_details
Step 3: Analyzing Wrap-Up Code Null Values
This is the critical step. When you inspect the ConversationDetail object, the wrap_up_code field (or wrapup_code depending on SDK serialization) can be null for three distinct reasons:
- No Wrap-Up Selected: The agent ended the call without selecting a code.
- Auto-Disposal/Timeout: The system ended the interaction before the agent could wrap up.
- Non-Voice or Unwrap-upable Context: While we filtered for
voice, some internal system calls or specific IVR-only paths may not generate a wrap-up code entity.
You must normalize this data. Treating null as an empty string can break downstream analytics if you expect a code ID.
def analyze_wrapup_codes(details: list):
"""
Processes conversation details to identify and categorize null wrap-up codes.
"""
null_wrapup_count = 0
valid_wrapup_count = 0
null_reasons = {
"no_code_selected": 0,
"auto_disposed": 0,
"system_call": 0
}
for detail in details:
# The wrap up code is typically found in the 'wrapup_code' attribute
# Note: SDK attribute names may vary slightly (e.g., wrapup_code vs wrap_up_code)
# We access the underlying dict if direct attribute access fails due to versioning
wrapup_code_id = getattr(detail, 'wrapup_code', None)
if wrapup_code_id is None:
null_wrapup_count += 1
# Heuristic to determine why it is null
# Check conversation direction and disposition
direction = getattr(detail, 'direction', None)
disposition = getattr(detail, 'disposition', None)
if direction == "auto-disposed" or disposition == "auto-disposed":
null_reasons["auto_disposed"] += 1
elif direction == "system" or getattr(detail, 'conversation_type', None) != "voice":
null_reasons["system_call"] += 1
else:
# Likely agent ended without selecting a code
null_reasons["no_code_selected"] += 1
else:
valid_wrapup_count += 1
summary = {
"total_conversations": len(details),
"null_wrapup_count": null_wrapup_count,
"valid_wrapup_count": valid_wrapup_count,
"null_reasons": null_reasons
}
return summary
Step 4: Debugging Query Filters vs. Data Reality
A common mistake is assuming that wrapupCode: null is a data error. It is often a query filter issue. If you want to exclude calls with no wrap-up code, you cannot simply filter wrapupCode != null in the basic query filter because the Analytics API filter syntax is limited.
Instead, you must fetch all voice calls and filter client-side, or use the metrics endpoint to count wrapped vs. unwrapped calls efficiently.
Here is how to refine the query to ensure you are not missing data due to incorrect time zones:
def ensure_utc_timezone(dt: datetime) -> datetime:
"""
Ensures the datetime object is timezone-aware and in UTC.
Genesys Cloud APIs strictly use UTC.
"""
if dt.tzinfo is None:
dt = pytz.UTC.localize(dt)
return dt.astimezone(pytz.UTC)
Complete Working Example
This script combines all steps into a runnable module. It queries the last 24 hours of voice conversations and reports on wrap-up code availability.
import os
import sys
import pytz
from datetime import datetime, timedelta
from genesyscloud import Configuration, ApiClient, AnalyticsApi, ConversationDetailQueryRequest, ConversationDetailQueryRequestFilter
# --- Configuration ---
def get_config():
config = Configuration()
config.host = os.getenv("GENESYS_CLOUD_REGION", "https://api.mypurecloud.com")
config.client_id = os.getenv("GENESYS_CLOUD_CLIENT_ID")
config.client_secret = os.getenv("GENESYS_CLOUD_CLIENT_SECRET")
if not config.client_id or not config.client_secret:
raise ValueError("Environment variables GENESYS_CLOUD_CLIENT_ID and GENESYS_CLOUD_CLIENT_SECRET are required.")
return config
# --- Query Construction ---
def build_query():
# Define time range: Last 24 hours
now = pytz.UTC.localize(datetime.utcnow())
start = now - timedelta(hours=24)
start_str = start.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
end_str = now.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
return ConversationDetailQueryRequest(
filter=ConversationDetailQueryRequestFilter(
conversation_types=["voice"]
),
interval_type="fixed",
from_=start_str,
to=end_str
)
# --- Execution ---
def main():
try:
config = get_config()
api_client = ApiClient(config)
analytics_api = AnalyticsApi(api_client)
query = build_query()
print("Starting Analytics Detail Query for Voice Conversations...")
print(f"Time Range: {query.from_} to {query.to}")
all_details = []
page_count = 0
while True:
page_count += 1
print(f"Fetching page {page_count}...")
try:
response = analytics_api.post_analytics_conversations_details_query(
body=query,
_preload_content=True
)
if response.entities:
all_details.extend(response.entities)
if response.next_page:
query.next_page = response.next_page
else:
break
except Exception as e:
print(f"Error on page {page_count}: {e}")
if "429" in str(e):
import time
print("Rate limited. Waiting 5 seconds...")
time.sleep(5)
else:
raise e
print(f"Total conversations fetched: {len(all_details)}")
# --- Analysis ---
null_count = 0
valid_count = 0
for detail in all_details:
# Accessing wrapup_code. In some SDK versions, this might be 'wrap_up_code'
# We use getattr for safety
code = getattr(detail, 'wrapup_code', None)
if code is None:
null_count += 1
else:
valid_count += 1
print("-" * 30)
print("Results Summary:")
print(f"Valid Wrap-Up Codes: {valid_count}")
print(f"Null Wrap-Up Codes: {null_count}")
if null_count > 0:
print("\nNote: Null wrap-up codes are expected for:")
print("1. Agents who ended the call without selecting a code.")
print("2. Auto-disposed calls (timeout).")
print("3. System-generated voice interactions.")
except Exception as e:
print(f"Fatal error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
Common Errors & Debugging
Error: 401 Unauthorized
- What causes it: Invalid client ID, secret, or expired token. The SDK handles token refresh, but the initial credentials must be valid.
- How to fix it: Verify
GENESYS_CLOUD_CLIENT_IDandGENESYS_CLOUD_CLIENT_SECRETin your environment. Ensure the client has not been disabled in the Genesys Cloud Admin console. - Code Check:
# Ensure the host is correct for your region config.host = "https://api.mypurecloud.com" # US East # config.host = "https://api.euw1.pure.cloud" # EU West 1
Error: 403 Forbidden
- What causes it: The OAuth client lacks the
analytics:detail:readscope. - How to fix it: Go to Admin > Platform > OAuth Clients. Select your client. In the Scopes tab, ensure
analytics:detail:readis checked. Save and re-run. - Debugging Tip: If you still see 403, check if the client is restricted to specific users or groups that do not have access to the analytics data.
Error: 429 Too Many Requests
- What causes it: Analytics queries are heavy. You have exceeded the rate limit for your organization or client.
- How to fix it: Implement exponential backoff. The Complete Working Example includes a basic retry logic for 429 errors.
- Optimization: Reduce the time window of your query. Querying 6 months of detailed data at once is likely to hit limits. Break it into 24-hour chunks.
Error: wrapup_code is always null even for wrapped calls
- What causes it: You are querying the wrong conversation type or the SDK version serializes the field differently.
- How to fix it:
- Verify
conversation_types=["voice"]. - Check the raw JSON response from the API using Postman or cURL to confirm the field exists.
- In the Python SDK, print
dir(detail)to inspect available attributes. The field might bewrap_up_code(with underscore) in older SDK versions.
# Debug snippet print(f"Available attributes: {[attr for attr in dir(detail) if not attr.startswith('_')]}") - Verify