Building Real-Time Analytics for Conversational AI
When you're running thousands of concurrent conversations, understanding what's happening in real-time isn't just nice to have—it's essential. Here's how we built analytics that process 3.2 million conversations per day with sub-second latency.
The Challenge
Traditional analytics work great for page views and button clicks. But conversations are different:
Conversations are:
- Multi-turn - a single conversation spans dozens of messages
- Stateful - context from message 1 affects message 20
- Non-linear - users jump between topics
- Outcome-oriented - success isn't "viewed page" but "achieved goal"
- Real-time - need insights while conversation is happening, not hours later
- Is my bot working right now?
- Why did this conversation fail?
- Which topics are users asking about?
- Are response times acceptable?
- What's my cost per conversation?
- Are users satisfied?
What customers need to know:
And they need answers in seconds, not hours.
Architecture Overview
User Message
↓
Lambda: Process Message
↓ (logs event)
↓
Kinesis Data Stream
↓ (real-time processing)
↓
Lambda: Aggregate Metrics ──→ DynamoDB (hot data, last 24hrs)
↓
Kinesis Firehose ──→ S3 (cold data, historical)
↓
Athena (SQL queries on S3)
Hot path: Real-time metrics (last 24 hours) in DynamoDB
Cold path: Historical data in S3, queryable via Athena
Event Schema
Every conversation event follows a consistent schema:
json
{
"eventId": "evt_abc123",
"timestamp": "2024-09-05T14:23:11.342Z",
"appId": "app_xyz789",
"conversationId": "conv_123456",
"userId": "user_987654",
"type": "message_received|message_sent|tool_called|outcome_reached|error",
"message": {
"content": "What are your prices?",
"tokens": 5,
"model": "gpt-4",
"latency_ms": 487
},
"tool": {
"name": "search_knowledge_base",
"duration_ms": 123,
"success": true
},
"outcome": {
"tier": "useful_outcome",
"value": 0.99,
"confidence": 0.95
},
"context": {
"ip": "192.168.1.1",
"userAgent": "Mozilla/5.0...",
"referrer": "https://google.com",
"location": "US-CA-SF"
}
}
Real-Time Processing
Kinesis Data Stream
Events flow into Kinesis for real-time processing:
Configuration:
Why Kinesis:
Aggregation Lambda
Processes events in micro-batches (100 events every 5 seconds):
``javascript
exports.handler = async (event) => {
const events = event.Records.map(r => JSON.parse(r.kinesis.data));
// Group by app and 5-minute time window
const aggregates = {};
for (const evt of events) {
const key = ${evt.appId}:${getTimeWindow(evt.timestamp)};
if (!aggregates[key]) {
aggregates[key] = {
appId: evt.appId,
window: getTimeWindow(evt.timestamp),
totalMessages: 0,
totalConversations: new Set(),
avgLatency: [],
errors: 0,
outcomes: {},
tools: {}
};
}
// Accumulate metrics
aggregates[key].totalMessages++;
aggregates[key].totalConversations.add(evt.conversationId);
if (evt.message) {
aggregates[key].avgLatency.push(evt.message.latency_ms);
}
if (evt.type === 'error') {
aggregates[key].errors++;
}
if (evt.outcome) {
aggregates[key].outcomes[evt.outcome.tier] =
(aggregates[key].outcomes[evt.outcome.tier] || 0) + 1;
}
if (evt.tool) {
aggregates[key].tools[evt.tool.name] =
(aggregates[key].tools[evt.tool.name] || 0) + 1;
}
}
// Write to DynamoDB
for (const aggregate of Object.values(aggregates)) {
await writeToDynamoDB({
pk: APP#${aggregate.appId},
sk: METRICS#${aggregate.window},
totalMessages: aggregate.totalMessages,
totalConversations: aggregate.totalConversations.size,
avgLatency: average(aggregate.avgLatency),
p95Latency: percentile(aggregate.avgLatency, 95),
errorRate: aggregate.errors / aggregate.totalMessages,
outcomes: aggregate.outcomes,
tools: aggregate.tools,
ttl: Date.now() / 1000 + 86400 // 24 hour TTL
});
}
};
Key decisions:
5-minute windows - balance between freshness and efficiency
TTL on hot data - auto-delete after 24 hours
Set for unique conversations - accurate conversation counts
P95 latency - better than average for understanding experience
Query API
Customers query metrics through our API:
javascript
// GET /api/analytics/metrics
// ?appId=xyz&start=2024-09-05T00:00:00Z&end=2024-09-05T23:59:59Z
exports.handler = async (event) => {
const { appId, start, end } = event.queryStringParameters;
// Query DynamoDB for recent data (last 24hrs)
if (isRecent(start, end)) {
const windows = getTimeWindows(start, end, '5m');
const results = await batchGetDynamoDB(
windows.map(w => ({
pk: APP#${appId},
sk: METRICS#${w}
}))
);
return aggregateResults(results);
}
// Query Athena for historical data
else {
const queryId = await athena.startQueryExecution({
QueryString:
SELECT
date_trunc('hour', timestamp) as hour,
COUNT(*) as total_messages,
COUNT(DISTINCT conversation_id) as total_conversations,
AVG(latency_ms) as avg_latency,
APPROX_PERCENTILE(latency_ms, 0.95) as p95_latency,
SUM(CASE WHEN type='error' THEN 1 ELSE 0 END) as errors
FROM conversation_events
WHERE app_id = '${appId}'
AND timestamp BETWEEN '${start}' AND '${end}'
GROUP BY date_trunc('hour', timestamp)
ORDER BY hour
,
ResultConfiguration: {
OutputLocation: 's3://analytics-results/'
}
});
return await waitForQueryResults(queryId);
}
};
Dashboard Metrics
Overview Dashboard
What customers see when they open the dashboard:
Top-line metrics (last 24 hours):
Total conversations
Active users
Average response time
Error rate
Customer satisfaction score
Trends (sparklines showing last 7 days):
Conversations per day
Response time over time
Error rate over time
Current status:
Messages in last 5 minutes (updates every 30 seconds)
Active conversations right now
Current response time
Conversation Explorer
Drill down into individual conversations:
Filters:
Date range
Outcome tier
Response time (slow, normal, fast)
Error status
User satisfaction rating
For each conversation:
Full transcript
Timeline with latency for each turn
Tools called
Outcome reached
Cost breakdown
User feedback (if any)
Topic Analysis
What are users actually talking about?
Implementation:
Extract key phrases from messages using TF-IDF
Group similar topics using clustering
Track topic volume over time
What it shows:
Top 20 topics this week
Trending topics (sudden spikes)
Topic sentiment (positive/negative)
Unresolved topics (user asked but bot couldn't answer)
Cost Analytics
Critical for outcome-based pricing:
Metrics:
Cost per conversation by outcome tier
LLM API costs per conversation
Tool execution costs
Total spend vs. budget
Forecasting:
Projected month-end spend
Cost trend (increasing/decreasing)
Cost per customer acquisition
ROI by conversation type
Performance Optimizations
1. Pre-aggregation
Don't calculate metrics on every query. Pre-aggregate:
javascript
// Bad: Calculate on query
SELECT AVG(latency_ms) FROM events
WHERE app_id = 'xyz' AND timestamp > now() - interval '24 hours'
// Good: Read pre-aggregated
SELECT avg_latency FROM aggregates
WHERE pk = 'APP#xyz' AND sk BETWEEN 'METRICS#2024-09-04' AND 'METRICS#2024-09-05'
Result: Query time reduced from 2.3s to 45ms.
2. Caching
Cache expensive queries:
javascript
const cacheKey = metrics:${appId}:${start}:${end};
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const results = await queryMetrics(appId, start, end);
await redis.setex(cacheKey, 300, JSON.stringify(results)); // 5 min cache
return results;
Result: 80% of queries served from cache.
3. Sampling for High-Volume Apps
For apps with >10,000 conversations/day:
javascript
// Sample 10% of events for analytics
if (Math.random() < 0.1) {
await sendToKinesis(event);
}
But always track critical events (errors, outcomes).
Result: 90% reduction in processing costs for high-volume apps, negligible accuracy impact.
4. Incremental Updates
Dashboard updates every 30 seconds with only new data:
javascript
// Client tracks last update
const lastUpdate = localStorage.getItem('lastUpdate') || now() - 3600000;
// Server returns only new data
const newEvents = await queryEvents({
appId,
since: lastUpdate
});
// Client merges with existing data
mergeMetrics(existingMetrics, newEvents);
Result: Dashboard updates in <200ms instead of reloading everything.
Alerting
Automated alerts based on metrics:
Alert Types
1. Error rate spike
javascript
if (errorRate > 5% && previousErrorRate < 2%) {
alert({
severity: 'high',
message: 'Error rate spiked from 1.2% to 6.7%',
appId: 'xyz',
actions: ['View errors', 'Disable app', 'Call on-call engineer']
});
}
2. Slow responses
javascript
if (p95Latency > 5000 && previousP95 < 2000) {
alert({
severity: 'medium',
message: 'Response time p95 increased from 1.8s to 5.2s'
});
}
3. Cost spike
javascript
if (todaySpend > yesterdaySpend * 2) {
alert({
severity: 'medium',
message: 'Spending is 2.3x yesterday ($127 vs $55)'
});
}
4. No traffic
javascript
if (messagesInLast10Min === 0 && usualTraffic > 100) {
alert({
severity: 'high',
message: 'No messages received in 10 minutes (usually 120/10min)'
});
}
Delivery
Email - digest once per day
Slack - critical alerts immediately
Dashboard - all alerts shown in app
SMS - only for severity='critical'
Privacy and Compliance
Analytics on conversations require careful handling:
PII Redaction
Before logging:
javascript
function redactPII(message) {
return message
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[EMAIL]')
.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN]')
.replace(/\b\d{16}\b/g, '[CARD]')
.replace(/\b\d{3}-\d{3}-\d{4}\b/g, '[PHONE]');
}
``
Data Retention
Customer Controls
Customers can:
Current Scale
As of September 2024:
What's Next
We're working on:
For Developers
Want to build similar analytics?
Key lessons:
1. Design events first - good schema makes everything easier
2. Pre-aggregate everything - don't calculate on query
3. Separate hot and cold paths - different tools for different time ranges
4. Cache aggressively - most queries are repeated
5. Sample when needed - 100% accuracy isn't always necessary
Tools we recommend:
Read our docs → | See API reference →
---
*Questions about our analytics architecture? Email hello@ilq.ai*
