prasadnu's picture
feat: add Search Personalization demo module
b4d5c9a
Raw
History Blame
14.5 kB
"""
UBI (User Behavior Insights) Tracker Module
This module provides configuration management and utility functions for UBI tracking
in the OpenSearch Product Catalog. It handles loading configuration from environment
variables and provides functions to check if UBI tracking is enabled.
"""
import os
from typing import Dict, Any, Optional
from dotenv import load_dotenv
import streamlit as st
import streamlit.components.v1 as components
import logging
# Import proxy functions
from search_personalization.ubi_proxy import send_ubi_query, send_ubi_event
# Load environment variables from .env file
load_dotenv()
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def is_ubi_enabled() -> bool:
"""
Check if UBI tracking is enabled.
UBI tracking is considered enabled if:
1. UBI_ENABLED environment variable is set to 'true' (case-insensitive)
2. Both UBI_EVENTS_ENDPOINT and UBI_QUERIES_ENDPOINT are configured
Returns:
bool: True if UBI tracking is enabled and properly configured, False otherwise
"""
# Check if UBI is explicitly enabled
enabled = os.getenv('UBI_ENABLED', 'true').lower() == 'true'
if not enabled:
return False
# Check if both ingestion endpoints are configured
events_endpoint = os.getenv('UBI_EVENTS_ENDPOINT')
queries_endpoint = os.getenv('UBI_QUERIES_ENDPOINT')
if not events_endpoint or not queries_endpoint:
print("WARNING: UBI tracking is enabled but UBI_EVENTS_ENDPOINT and/or UBI_QUERIES_ENDPOINT are not configured")
return False
return True
def get_ubi_config() -> Dict[str, Any]:
"""
Get UBI configuration from environment variables.
Returns a dictionary containing all UBI-related configuration values:
- events_endpoint: OpenSearch Ingestion Service endpoint for UBI events
- queries_endpoint: OpenSearch Ingestion Service endpoint for UBI queries
- aws_region: AWS region for the ingestion service
- debug_mode: Whether to enable debug logging
- enabled: Whether UBI tracking is enabled
- application_name: Name of the application for UBI data
Returns:
Dict[str, Any]: Dictionary containing UBI configuration
"""
config = {
'events_endpoint': os.getenv('UBI_EVENTS_ENDPOINT'),
'queries_endpoint': os.getenv('UBI_QUERIES_ENDPOINT'),
'aws_region': os.getenv('AWS_REGION', 'us-east-1'),
'debug_mode': os.getenv('UBI_DEBUG_MODE', 'false').lower() == 'true',
'enabled': is_ubi_enabled(),
'application_name': os.getenv('UBI_APPLICATION_NAME', 'os-product-catalog')
}
return config
def validate_ubi_config() -> tuple[bool, Optional[str]]:
"""
Validate UBI configuration.
Checks that all required configuration values are present and valid.
Returns:
tuple[bool, Optional[str]]: (is_valid, error_message)
- is_valid: True if configuration is valid, False otherwise
- error_message: Description of validation error, or None if valid
"""
config = get_ubi_config()
if not config['enabled']:
return True, None # Valid to have UBI disabled
# Check required fields when UBI is enabled
if not config['events_endpoint']:
return False, "UBI_EVENTS_ENDPOINT is required when UBI_ENABLED is true"
if not config['queries_endpoint']:
return False, "UBI_QUERIES_ENDPOINT is required when UBI_ENABLED is true"
# Validate endpoints are HTTPS
if not config['events_endpoint'].startswith('https://'):
return False, "UBI_EVENTS_ENDPOINT must be an HTTPS URL"
if not config['queries_endpoint'].startswith('https://'):
return False, "UBI_QUERIES_ENDPOINT must be an HTTPS URL"
if not config['aws_region']:
return False, "AWS_REGION is required for UBI tracking"
if not config['application_name']:
return False, "UBI_APPLICATION_NAME is required for UBI tracking"
return True, None
def create_ubi_tracker(
ingestion_endpoint: str,
aws_region: str,
debug_mode: bool = False
) -> bool:
"""
Create and initialize the UBI tracking component.
This function embeds a custom HTML/JavaScript component in the Streamlit app
that handles UBI tracking on the client side. The component manages Client_ID,
Session_ID, and Query_ID, formats UBI data, and sends it to OpenSearch Ingestion Service.
The component communicates Client_ID and Session_ID back to Python via Streamlit's
component API, allowing the backend proxy to use these IDs when tracking.
Args:
ingestion_endpoint: OpenSearch Ingestion Service HTTPS endpoint
aws_region: AWS region for SigV4 signing
debug_mode: Enable console logging for debugging (default: False)
Returns:
bool: True if component initialized successfully, False otherwise
Requirements: 8.4, 8.5, 12.4, 12.5
"""
# Read the HTML template
template_path = os.path.join(os.path.dirname(__file__), 'ubi_component_template.html')
try:
with open(template_path, 'r') as f:
html_template = f.read()
except FileNotFoundError:
logger.error(f"UBI component template not found at {template_path}")
# Set flag to disable tracking if component fails
if 'ubi_component_failed' not in st.session_state:
st.session_state.ubi_component_failed = True
return False
except Exception as e:
logger.error(f"Error reading UBI component template: {e}")
if 'ubi_component_failed' not in st.session_state:
st.session_state.ubi_component_failed = True
return False
try:
# Inject configuration into the HTML
config_script = f"""
<script>
// Initialize UBI client with configuration
document.addEventListener('DOMContentLoaded', function() {{
try {{
if (typeof initializeUbiClient === 'function') {{
initializeUbiClient({{
ingestionEndpoint: '{ingestion_endpoint}',
awsRegion: '{aws_region}',
debugMode: {str(debug_mode).lower()}
}});
}} else {{
console.error('UBI: initializeUbiClient function not found');
}}
}} catch (error) {{
console.error('UBI: Failed to initialize client:', error);
}}
}});
</script>
"""
# Combine template with configuration
full_html = html_template.replace('</body>', f'{config_script}</body>')
# Embed the component and capture return value
component_value = components.html(full_html, height=0, scrolling=False)
# Store Client_ID and Session_ID in session state if returned from component
if component_value and isinstance(component_value, dict):
if 'client_id' in component_value:
st.session_state.ubi_client_id = component_value['client_id']
if debug_mode:
logger.info(f"Received Client_ID from component: {component_value['client_id']}")
if 'session_id' in component_value:
st.session_state.ubi_session_id = component_value['session_id']
if debug_mode:
logger.info(f"Received Session_ID from component: {component_value['session_id']}")
# Mark component as successfully loaded
if 'ubi_component_failed' in st.session_state:
del st.session_state.ubi_component_failed
return True
except Exception as e:
logger.error(f"Error initializing UBI component: {e}")
# Set flag to disable tracking if component fails
if 'ubi_component_failed' not in st.session_state:
st.session_state.ubi_component_failed = True
return False
def track_query(query_text: str, query_id: str, user_id: Optional[str] = None, query_response_hit_ids: Optional[list] = None) -> None:
"""
Track a search query by sending it to the backend proxy.
This function formats the query data and sends it to OpenSearch Ingestion Service
via the backend proxy with AWS SigV4 authentication.
Args:
query_text: The user's search query
query_id: Unique identifier for this query
user_id: User identifier for persona tracking (optional)
query_response_hit_ids: List of document IDs returned by the search in order (optional)
Requirements: 8.4, 8.5, 9.1, 9.2, 12.4, 12.5
"""
try:
# Check if component failed to load
if st.session_state.get('ubi_component_failed', False):
logger.debug("UBI component failed to load, skipping query tracking")
return
# Get configuration
config = get_ubi_config()
if not config['enabled']:
logger.debug("UBI tracking is disabled, skipping query tracking")
return
# Get Client_ID from session state (should be set by component)
client_id = st.session_state.get('ubi_client_id', 'unknown')
# Format query data
from datetime import datetime
query_data = {
'application': config['application_name'],
'query_id': query_id,
'client_id': client_id,
'user_query': query_text or '',
'object_id_field': 'id',
'query_attributes': {},
'timestamp': datetime.utcnow().isoformat() + 'Z'
}
# Add user_id if provided
if user_id:
query_data['user_id'] = user_id
# Add query_response_hit_ids if provided
if query_response_hit_ids is not None:
query_data['query_response_hit_ids'] = query_response_hit_ids
# Send via proxy
success, error = send_ubi_query(query_data)
if not success:
logger.error(f"Failed to track query: {error}")
elif config['debug_mode']:
logger.info(f"Query tracked successfully: {query_id}")
except Exception as e:
logger.exception(f"Error tracking query: {e}")
# Silent failure - do not disrupt user experience
def track_event(
action_name: str,
query_id: str,
object_id: Optional[str] = None,
event_attributes: Optional[Dict[str, Any]] = None,
message: Optional[str] = None,
message_type: Optional[str] = None,
user_id: Optional[str] = None
) -> None:
"""
Track a user event by sending it to the backend proxy.
This function formats the event data and sends it to OpenSearch Ingestion Service
via the backend proxy with AWS SigV4 authentication.
Args:
action_name: Type of event (search, add_to_cart, hover, etc.)
query_id: Query ID to associate with this event
object_id: Product ID (for product-related events)
event_attributes: Additional event metadata
message: Human-readable event description
message_type: Message type (INFO, QUERY, etc.) - defaults to QUERY for search, INFO otherwise
user_id: User identifier for persona tracking (optional)
Requirements: 8.4, 8.5, 9.1, 9.2, 12.4, 12.5
"""
try:
# Check if component failed to load
if st.session_state.get('ubi_component_failed', False):
logger.debug("UBI component failed to load, skipping event tracking")
return
# Get configuration
config = get_ubi_config()
if not config['enabled']:
logger.debug("UBI tracking is disabled, skipping event tracking")
return
# Get Client_ID and Session_ID from session state
client_id = st.session_state.get('ubi_client_id', 'unknown')
session_id = st.session_state.get('ubi_session_id', 'unknown')
# Determine message_type if not provided
if message_type is None:
message_type = 'QUERY' if action_name == 'search' else 'INFO'
# Format event data
from datetime import datetime
event_data = {
'application': config['application_name'],
'action_name': action_name,
'query_id': query_id,
'client_id': client_id,
'session_id': session_id,
'user_id': user_id or '',
'timestamp': datetime.utcnow().isoformat() + 'Z',
'message_type': message_type,
'message': message or ''
}
# Add event_attributes if provided
if event_attributes:
event_data['event_attributes'] = event_attributes
# Ensure object_id_field is set if object exists
if event_attributes.get('object') and event_attributes['object'].get('object_id'):
if not event_attributes['object'].get('object_id_field'):
event_attributes['object']['object_id_field'] = 'id'
# Ensure position.ordinal exists for events with object_id
if not event_attributes.get('position'):
event_attributes['position'] = {'ordinal': 0}
elif not isinstance(event_attributes['position'].get('ordinal'), int):
event_attributes['position']['ordinal'] = 0
elif object_id:
# If objectId is provided but no eventAttributes, create minimal structure
event_data['event_attributes'] = {
'object': {
'object_id': object_id,
'object_id_field': 'id'
},
'position': {
'ordinal': 0
}
}
# Send via proxy
success, error = send_ubi_event(event_data)
if not success:
logger.error(f"Failed to track event: {error}")
elif config['debug_mode']:
logger.info(f"Event tracked successfully: {action_name}")
except Exception as e:
logger.exception(f"Error tracking event: {e}")
# Silent failure - do not disrupt user experience