Analytics ETL Pipeline Design - MongoDB Edition¶
AI Chat System Usage Analytics with MongoDB Data Warehouse¶
Version: 1.0 (MongoDB Variant)
Date: November 10, 2025
Status: Design Proposal
Table of Contents¶
- Overview
- MongoDB vs PostgreSQL Decision
- MongoDB Data Warehouse Schema
- ETL Pipeline for MongoDB
- API Implementation with MongoDB
- Performance Optimization
- Pros and Cons
- Implementation Guide
Overview¶
This document adapts the analytics ETL design to use MongoDB as the data warehouse instead of PostgreSQL. This approach leverages your existing MongoDB infrastructure and avoids introducing a new database technology.
Key Differences from PostgreSQL Design¶
| Aspect | PostgreSQL Design | MongoDB Design |
|---|---|---|
| Schema | Star schema (normalized) | Denormalized documents |
| Joins | SQL JOINs | Embedded documents or $lookup |
| Aggregations | GROUP BY, window functions | Aggregation pipeline |
| Indexes | B-tree, GIN, etc. | Single/compound/text indexes |
| Storage | Separate database | Separate MongoDB database |
| Query Language | SQL | MongoDB aggregation framework |
MongoDB vs PostgreSQL Decision¶
Pros of Using MongoDB¶
✅ No New Infrastructure - Use existing MongoDB servers - No new database to manage - Familiar technology for your team - Same backup/monitoring tools
✅ Operational Simplicity - Single database technology - Unified security/access control - Easier deployment - Lower operational overhead
✅ Flexible Schema - Easy to add new fields - No schema migrations needed - Handles varying document structures well
✅ Good for Document-Heavy Data - Questions/answers are text-heavy (good fit) - Nested data structures natural - JSON-like documents easy to work with
✅ Fast Development - Your team already knows MongoDB - Faster initial implementation - Less learning curve
Cons of Using MongoDB¶
❌ Less Optimized for Analytics - Aggregation pipeline more verbose than SQL - No window functions (running totals, etc.) - Limited support for complex joins - GROUP BY operations less efficient than PostgreSQL
❌ Query Performance - Generally 2-5x slower than PostgreSQL for analytics queries - Aggregation pipeline less optimized - Index-only queries harder to achieve
❌ Storage Efficiency - Denormalized data = more storage - Repeated data across documents - Can be 2-3x larger than normalized SQL
❌ Limited Analytics Features - No materialized views - No CTEs (Common Table Expressions) - No PARTITION BY for window functions - Less sophisticated query optimizer
❌ Aggregation Complexity - Complex aggregations more difficult to write - Longer pipeline stages - Harder to debug
Performance Comparison¶
| Query Type | PostgreSQL | MongoDB | Winner |
|---|---|---|---|
| Simple aggregations | 50ms | 150ms | PostgreSQL (3x) |
| Complex joins | 100ms | 500ms | PostgreSQL (5x) |
| Text search | 200ms | 150ms | MongoDB (1.3x) |
| Time-series aggregations | 80ms | 250ms | PostgreSQL (3x) |
| Document retrieval | 10ms | 5ms | MongoDB (2x) |
| Full-text search | 300ms | 100ms | MongoDB (3x) |
Overall: PostgreSQL is 2-5x faster for analytics workloads, but MongoDB is faster for document retrieval and text search.
Recommendation¶
Use MongoDB if: - ✅ You want to avoid adding new infrastructure - ✅ Operational simplicity is priority #1 - ✅ Your team lacks PostgreSQL experience - ✅ 2-5x slower queries are acceptable (still 10-20x faster than current) - ✅ You need fast text search - ✅ Storage cost is not a concern
Use PostgreSQL if: - ✅ You need maximum query performance - ✅ You have complex analytical requirements - ✅ Storage efficiency matters - ✅ You need advanced SQL features (window functions, CTEs) - ✅ You're willing to manage another database
For your use case: MongoDB is a reasonable choice for MVP, with option to migrate to PostgreSQL later if performance becomes an issue.
MongoDB Data Warehouse Schema¶
Database Structure¶
MongoDB Server
├── <catname>_cat_data (operational - existing)
│ └── logs (raw chat logs - source data)
│
└── bast_analytics (new - analytics warehouse)
├── dim_cat (dimension)
├── dim_topic (dimension)
├── dim_model (dimension)
├── dim_question (dimension)
├── dim_answer (dimension)
├── dim_session (dimension)
├── dim_user (dimension)
├── fact_interaction (atomic facts)
├── fact_hourly_summary (pre-aggregated)
├── fact_daily_summary (pre-aggregated)
├── fact_weekly_summary (pre-aggregated)
├── fact_monthly_summary (pre-aggregated)
├── etl_log (metadata)
├── etl_checkpoint (metadata)
└── data_quality_metrics (metadata)
Dimension Collections¶
1. dim_cat¶
{
_id: ObjectId("..."),
cat_name: "testcat", // Indexed (unique)
cat_prefix: "test",
cat_description: "Test catalog",
is_active: true,
created_at: ISODate("2025-01-01T00:00:00Z"),
updated_at: ISODate("2025-01-01T00:00:00Z")
}
// Indexes
db.dim_cat.createIndex({ cat_name: 1 }, { unique: true });
2. dim_topic¶
{
_id: ObjectId("..."),
topic_label: "advising", // Indexed (unique)
topic_category: "academic", // Optional parent category
topic_description: "Academic advising questions",
is_active: true,
created_at: ISODate("2025-01-01T00:00:00Z"),
updated_at: ISODate("2025-01-01T00:00:00Z")
}
// Indexes
db.dim_topic.createIndex({ topic_label: 1 }, { unique: true });
3. dim_model¶
{
_id: ObjectId("..."),
model_name: "gpt-4o", // Indexed (unique)
model_provider: "OpenAI",
model_version: "2024-05-13",
model_type: "LLM",
is_active: true,
created_at: ISODate("2025-01-01T00:00:00Z"),
updated_at: ISODate("2025-01-01T00:00:00Z")
}
// Indexes
db.dim_model.createIndex({ model_name: 1 }, { unique: true });
4. dim_persona (NEW)¶
{
_id: ObjectId("..."),
persona_name: "teacher", // Indexed (unique)
persona_category: "user_type", // Optional category
persona_description: "Teacher persona",
is_active: true,
created_at: ISODate("2025-01-01T00:00:00Z"),
updated_at: ISODate("2025-01-01T00:00:00Z")
}
// Indexes
db.dim_persona.createIndex({ persona_name: 1 }, { unique: true });
5. dim_question (Deduplication)¶
{
_id: ObjectId("..."),
question_text: "How do I register for classes?",
question_hash: "a3f2b8c...", // SHA256 hash (indexed, unique)
first_asked_at: ISODate("2025-01-01T10:00:00Z"),
last_asked_at: ISODate("2025-01-10T15:30:00Z"),
total_ask_count: 42, // Updated on each occurrence
created_at: ISODate("2025-01-01T10:00:00Z")
}
// Indexes
db.dim_question.createIndex({ question_hash: 1 }, { unique: true });
db.dim_question.createIndex({ question_text: "text" }); // Full-text search
6. dim_answer (Deduplication)¶
{
_id: ObjectId("..."),
answer_text: "To register for classes, go to...",
answer_hash: "b4e3c9d...", // SHA256 hash (indexed, unique)
first_given_at: ISODate("2025-01-01T10:00:00Z"),
last_given_at: ISODate("2025-01-10T15:30:00Z"),
total_given_count: 42,
created_at: ISODate("2025-01-01T10:00:00Z")
}
// Indexes
db.dim_answer.createIndex({ answer_hash: 1 }, { unique: true });
db.dim_answer.createIndex({ answer_text: "text" }); // Full-text search
7. dim_session¶
{
_id: ObjectId("..."),
session_id: "uuid-string", // Indexed (unique)
user_id: "user123", // Optional
session_start_time: ISODate("2025-01-01T10:00:00Z"),
session_end_time: ISODate("2025-01-01T10:45:00Z"),
total_interactions: 5,
created_at: ISODate("2025-01-01T10:00:00Z"),
updated_at: ISODate("2025-01-01T10:45:00Z")
}
// Indexes
db.dim_session.createIndex({ session_id: 1 }, { unique: true });
db.dim_session.createIndex({ user_id: 1 });
8. dim_user¶
{
_id: ObjectId("..."),
user_id: "user123", // Indexed (unique)
user_type: "student", // student, faculty, staff
user_segment: "power_user", // power_user, occasional, new
first_interaction_date: ISODate("2025-01-01"),
last_interaction_date: ISODate("2025-01-10"),
total_interactions: 25,
is_active: true,
created_at: ISODate("2025-01-01T00:00:00Z"),
updated_at: ISODate("2025-01-10T15:30:00Z")
}
// Indexes
db.dim_user.createIndex({ user_id: 1 }, { unique: true });
Fact Collections¶
Design Choice: Embedded vs Referenced¶
Option 1: Embedded Dimensions (Recommended for MongoDB) - Denormalize dimension data directly into facts - Faster queries (no $lookup needed) - More storage (duplicated data) - Best for MongoDB performance
Option 2: Referenced Dimensions - Store ObjectId references - Normalized (less storage) - Requires $lookup (slower) - More like relational design
Recommendation: Use embedded dimensions for better MongoDB performance.
1. fact_interaction (Atomic Grain)¶
Grain: One document per chat interaction
Design: Embedded (denormalized for performance)
{
_id: ObjectId("..."),
// Time dimensions (denormalized for fast filtering)
ts: 1704110400.123, // Original epoch timestamp
date: ISODate("2025-01-01T00:00:00Z"),
year: 2025,
month: 1,
week: 1,
day_of_month: 1,
day_of_week: 1, // 1=Monday, 7=Sunday
hour: 14,
minute: 30,
// CRITICAL: Event field (must match f"{cat.prefix}_api")
event: "test_api", // Event type filter
// Dimension data (embedded for performance)
cat: {
_id: ObjectId("..."),
name: "testcat"
},
topic: {
_id: ObjectId("..."),
label: "advising"
},
model: {
_id: ObjectId("..."),
name: "gpt-4o"
},
// NEW: Persona dimension (embedded)
persona: {
_id: ObjectId("..."),
name: "teacher" // or "default_value" if missing
},
question: {
_id: ObjectId("..."),
text: "How do I register for classes?",
hash: "a3f2b8c..."
},
answer: {
_id: ObjectId("..."),
text: "To register for classes...",
hash: "b4e3c9d..."
},
session: {
_id: ObjectId("..."),
session_id: "uuid-string"
},
user: {
_id: ObjectId("..."),
user_id: "user123"
},
// Measures (facts)
response_time_ms: 245,
token_count_input: 25,
token_count_output: 150,
cost_usd: 0.0023,
has_error: false,
is_valid_response: true,
// Source reference
source_log_id: "64a1b2c3d4e5f6g7h8i9j0k1", // Original MongoDB _id
// Metadata
created_at: ISODate("2025-01-01T14:30:00Z")
}
// Indexes (CRITICAL for performance)
db.fact_interaction.createIndex({ date: 1, hour: 1 });
db.fact_interaction.createIndex({ "cat.name": 1, date: 1 });
db.fact_interaction.createIndex({ "topic.label": 1, date: 1 });
db.fact_interaction.createIndex({ "model.name": 1, date: 1 });
db.fact_interaction.createIndex({ year: 1, month: 1 });
db.fact_interaction.createIndex({ ts: 1 });
db.fact_interaction.createIndex({ source_log_id: 1 }, { unique: true });
// Compound indexes for common queries
db.fact_interaction.createIndex({ "cat.name": 1, "topic.label": 1, date: 1 });
db.fact_interaction.createIndex({ "cat.name": 1, year: 1, month: 1 });
2. fact_hourly_summary¶
Grain: One document per hour, per cat, per topic, per model
{
_id: ObjectId("..."),
// Time dimension
date: ISODate("2025-01-01T00:00:00Z"),
year: 2025,
month: 1,
week: 1,
hour: 14, // 0-23
// CRITICAL: Event field (for filtering)
event: "test_api",
// Dimension keys (embedded)
cat: {
_id: ObjectId("..."),
name: "testcat"
},
topic: {
_id: ObjectId("..."),
label: "advising"
},
model: {
_id: ObjectId("..."),
name: "gpt-4o"
},
persona: {
_id: ObjectId("..."),
name: "teacher"
},
// Aggregated measures
total_interactions: 125,
unique_users: 45, // Count distinct user_ids
unique_sessions: 67, // Count distinct session_ids
unique_questions: 89, // Count distinct question_ids
avg_response_time_ms: 245.3,
total_tokens: 15000,
total_cost_usd: 2.45,
error_count: 2,
error_rate: 1.6, // Percentage
// Metadata
created_at: ISODate("2025-01-01T15:00:00Z"),
updated_at: ISODate("2025-01-01T15:00:00Z")
}
// Indexes
db.fact_hourly_summary.createIndex({ date: 1, hour: 1 });
db.fact_hourly_summary.createIndex({ "cat.name": 1, date: 1 });
db.fact_hourly_summary.createIndex({ year: 1, month: 1, hour: 1 });
// Unique constraint (prevents duplicates) - includes event and persona
db.fact_hourly_summary.createIndex(
{ "cat.name": 1, event: 1, "topic.label": 1, "model.name": 1, "persona.name": 1, date: 1, hour: 1 },
{ unique: true }
);
3. fact_daily_summary¶
Grain: One document per day, per cat, per topic, per model, per persona
{
_id: ObjectId("..."),
// Time dimension
date: ISODate("2025-01-01T00:00:00Z"),
year: 2025,
month: 1,
week: 1,
day_of_month: 1,
day_of_week: 1,
// CRITICAL: Event field (for filtering)
event: "test_api",
// Dimension keys (embedded)
cat: {
_id: ObjectId("..."),
name: "testcat"
},
topic: {
_id: ObjectId("..."),
label: "advising"
},
model: {
_id: ObjectId("..."),
name: "gpt-4o"
},
persona: {
_id: ObjectId("..."),
name: "teacher"
},
// Aggregated measures
total_interactions: 1250,
unique_users: 345,
unique_sessions: 567,
unique_questions: 789,
avg_response_time_ms: 245.3,
total_tokens: 150000,
total_cost_usd: 24.50,
error_count: 15,
error_rate: 1.2,
// Additional daily metrics
peak_hour: 14, // Hour with most traffic
peak_hour_count: 250, // Interactions in peak hour
// Metadata
created_at: ISODate("2025-01-02T02:00:00Z"),
updated_at: ISODate("2025-01-02T02:00:00Z")
}
// Indexes
db.fact_daily_summary.createIndex({ date: 1 });
db.fact_daily_summary.createIndex({ "cat.name": 1, date: 1 });
db.fact_daily_summary.createIndex({ year: 1, month: 1 });
// Unique constraint - includes event and persona for correct filtering
db.fact_daily_summary.createIndex(
{ "cat.name": 1, event: 1, "topic.label": 1, "model.name": 1, "persona.name": 1, date: 1 },
{ unique: true }
);
4. fact_weekly_summary¶
{
_id: ObjectId("..."),
// Time dimension
year: 2025,
week: 1, // ISO week number (1-53)
week_start_date: ISODate("2024-12-30T00:00:00Z"), // Monday
week_end_date: ISODate("2025-01-05T00:00:00Z"), // Sunday
// CRITICAL: Event field (for filtering)
event: "test_api",
// Dimension keys (embedded)
cat: { _id: ObjectId("..."), name: "testcat" },
topic: { _id: ObjectId("..."), label: "advising" },
model: { _id: ObjectId("..."), name: "gpt-4o" },
persona: { _id: ObjectId("..."), name: "teacher" },
// Aggregated measures
total_interactions: 8750,
unique_users: 1234,
unique_sessions: 2345,
unique_questions: 3456,
avg_response_time_ms: 245.3,
total_tokens: 1050000,
total_cost_usd: 171.50,
error_count: 105,
error_rate: 1.2,
// Week-over-week growth
wow_growth_pct: 5.3, // % change from previous week
// Metadata
created_at: ISODate("2025-01-06T02:00:00Z"),
updated_at: ISODate("2025-01-06T02:00:00Z")
}
// Indexes
db.fact_weekly_summary.createIndex({ year: 1, week: 1 });
db.fact_weekly_summary.createIndex({ "cat.name": 1, year: 1, week: 1 });
// Unique constraint - includes event and persona
db.fact_weekly_summary.createIndex(
{ "cat.name": 1, event: 1, "topic.label": 1, "model.name": 1, "persona.name": 1, year: 1, week: 1 },
{ unique: true }
);
5. fact_monthly_summary¶
{
_id: ObjectId("..."),
// Time dimension
year: 2025,
month: 1,
month_name: "January",
// CRITICAL: Event field (for filtering)
event: "test_api",
// Dimension keys (embedded)
cat: { _id: ObjectId("..."), name: "testcat" },
topic: { _id: ObjectId("..."), label: "advising" },
model: { _id: ObjectId("..."), name: "gpt-4o" },
persona: { _id: ObjectId("..."), name: "teacher" },
// Aggregated measures
total_interactions: 38750,
unique_users: 4567,
unique_sessions: 7890,
unique_questions: 9876,
avg_response_time_ms: 245.3,
total_tokens: 4650000,
total_cost_usd: 758.50,
error_count: 465,
error_rate: 1.2,
// Month-over-month growth
mom_growth_pct: 12.5, // % change from previous month
// Metadata
created_at: ISODate("2025-02-01T02:00:00Z"),
updated_at: ISODate("2025-02-01T02:00:00Z")
}
// Indexes
db.fact_monthly_summary.createIndex({ year: 1, month: 1 });
db.fact_monthly_summary.createIndex({ "cat.name": 1, year: 1, month: 1 });
// Unique constraint - includes event and persona
db.fact_monthly_summary.createIndex(
{ "cat.name": 1, event: 1, "topic.label": 1, "model.name": 1, "persona.name": 1, year: 1, month: 1 },
{ unique: true }
);
Metadata Collections¶
1. etl_log¶
{
_id: ObjectId("..."),
job_name: "incremental_etl_testcat",
job_start_time: ISODate("2025-01-01T14:00:00Z"),
job_end_time: ISODate("2025-01-01T14:05:23Z"),
status: "success", // running, success, failed
records_extracted: 1250,
records_loaded: 1245,
error_message: null,
created_at: ISODate("2025-01-01T14:00:00Z")
}
// Indexes
db.etl_log.createIndex({ job_name: 1, job_start_time: -1 });
db.etl_log.createIndex({ status: 1 });
2. etl_checkpoint¶
{
_id: ObjectId("..."),
cat_name: "testcat",
last_processed_ts: 1704110400.123,
last_processed_log_id: "64a1b2c3d4e5f6g7h8i9j0k1",
last_run_time: ISODate("2025-01-01T14:05:23Z")
}
// Indexes
db.etl_checkpoint.createIndex({ cat_name: 1 }, { unique: true });
3. data_quality_metrics¶
{
_id: ObjectId("..."),
check_date: ISODate("2025-01-01T00:00:00Z"),
check_name: "missing_topic_labels",
check_description: "Count of logs without topic_label",
total_records: 10000,
failed_records: 125,
failure_rate: 1.25,
severity: "medium", // low, medium, high
created_at: ISODate("2025-01-02T02:00:00Z")
}
// Indexes
db.data_quality_metrics.createIndex({ check_date: -1 });
db.data_quality_metrics.createIndex({ severity: 1, check_date: -1 });
ETL Pipeline for MongoDB¶
Extract Phase (extract.py)¶
from datetime import datetime
from typing import Dict, List, Any
from bast_ai_mongo import getNativePyMongo
from bast_ai_config import get_bast_config
from bast_ai.base import Stopwatch
import logging
logger = logging.getLogger(__name__)
class MongoLogExtractor:
"""Extracts new logs from MongoDB operational database."""
def __init__(self, cat_name: str, batch_size: int = 10000):
self.cat_name = cat_name
self.batch_size = batch_size
# Source database (operational logs)
bast_config = get_bast_config()
self.cat = bast_config.get_cat(catname=cat_name)
source_mongodb = getNativePyMongo(self.cat.mongodb)
db_name = f"{self.cat.prefix}_cat_data"
self.source_collection = source_mongodb[db_name]['logs']
# Warehouse database (for checkpoint)
# Assume warehouse is on same MongoDB server for simplicity
self.warehouse_db = source_mongodb['bast_analytics']
self.checkpoint_collection = self.warehouse_db['etl_checkpoint']
def get_last_checkpoint(self) -> float:
"""Get last processed timestamp from warehouse."""
checkpoint = self.checkpoint_collection.find_one({'cat_name': self.cat_name})
if checkpoint:
return checkpoint['last_processed_ts']
return 0.0 # Start from beginning if no checkpoint
def extract_new_logs(self, checkpoint_ts: float) -> List[Dict[str, Any]]:
"""
Extract logs newer than checkpoint timestamp.
Args:
checkpoint_ts: Last processed timestamp (epoch seconds)
Returns:
List of log documents
"""
sw = Stopwatch()
sw.start()
# Query for logs newer than checkpoint
# CRITICAL: Must filter by event field (only API events)
event_name = f"{self.cat.prefix}_api"
query = {
'ts': {'$gt': checkpoint_ts},
'event': event_name # Filter by event type
}
# Use cursor with limit and sort
cursor = self.source_collection.find(query).sort('ts', 1).limit(self.batch_size)
logs = list(cursor)
sw.stop()
logger.info(f"Extracted {len(logs)} logs in {sw.duration:.2f}s")
return logs
def extract_incremental(self) -> List[Dict[str, Any]]:
"""Extract logs since last checkpoint."""
checkpoint_ts = self.get_last_checkpoint()
logger.info(f"Extracting logs since timestamp: {checkpoint_ts}")
return self.extract_new_logs(checkpoint_ts)
Transform Phase (transform.py)¶
from typing import Dict, List, Any, Optional
from datetime import datetime
import hashlib
import logging
logger = logging.getLogger(__name__)
class LogTransformer:
"""Transforms raw MongoDB logs into warehouse format."""
def __init__(self):
pass
def validate_log(self, log: Dict[str, Any]) -> bool:
"""Validate log entry has required fields."""
required_fields = ['_id', 'ts', 'event'] # Added 'event' as required
for field in required_fields:
if field not in log:
logger.warning(f"Missing required field: {field}")
return False
if not isinstance(log['ts'], (int, float)):
logger.warning(f"Invalid ts type: {type(log['ts'])}")
return False
# Validate event matches expected pattern
catname = log.get('catname', '')
expected_event = f"{catname}_api"
if log.get('event') != expected_event:
logger.warning(f"Unexpected event: {log.get('event')}, expected: {expected_event}")
# Still process but log warning
return True
def clean_text(self, text: Optional[str]) -> Optional[str]:
"""Clean and normalize text fields."""
if text is None:
return None
text = text.strip()
if not text:
return None
# Normalize whitespace
text = ' '.join(text.split())
return text
def hash_text(self, text: str) -> str:
"""Generate SHA256 hash for deduplication."""
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def extract_date_parts(self, timestamp: float) -> Dict[str, Any]:
"""Extract date parts from epoch timestamp."""
dt = datetime.fromtimestamp(timestamp)
return {
'ts': timestamp,
'date': datetime(dt.year, dt.month, dt.day), # ISODate at day level
'year': dt.year,
'month': dt.month,
'week': dt.isocalendar()[1],
'day_of_month': dt.day,
'day_of_week': dt.isoweekday(),
'hour': dt.hour,
'minute': dt.minute
}
def transform_log(self, log: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Transform a single log entry.
Args:
log: Raw log document
Returns:
Transformed log dict or None if invalid
"""
if not self.validate_log(log):
return None
# Extract date/time parts
date_parts = self.extract_date_parts(log['ts'])
# Clean text fields
input_text = self.clean_text(log.get('input_text'))
output_text = self.clean_text(log.get('output'))
topic_label = self.clean_text(log.get('topic_label'))
# Build transformed record (matches fact_interaction schema)
transformed = {
# Time dimensions
'ts': date_parts['ts'],
'date': date_parts['date'],
'year': date_parts['year'],
'month': date_parts['month'],
'week': date_parts['week'],
'day_of_month': date_parts['day_of_month'],
'day_of_week': date_parts['day_of_week'],
'hour': date_parts['hour'],
'minute': date_parts['minute'],
# Raw dimension data (will be looked up in load phase)
'event': log.get('event'), # CRITICAL: Capture event field
'cat_name': log.get('catname') or 'unknown',
'topic_label': topic_label,
'model_name': log.get('membership') or 'unknown',
'persona_name': log.get('persona') or 'default_value', # NEW: Persona field
'session_id': log.get('session_id'),
'user_id': log.get('user_id'),
# Question/Answer
'question_text': input_text,
'question_hash': self.hash_text(input_text) if input_text else None,
'answer_text': output_text,
'answer_hash': self.hash_text(output_text) if output_text else None,
# Measures
'response_time_ms': log.get('response_time_ms'),
'token_count_input': log.get('token_count_input'),
'token_count_output': log.get('token_count_output'),
'cost_usd': log.get('cost_usd'),
'has_error': log.get('has_error', False),
# Source reference
'source_log_id': str(log['_id']),
}
return transformed
def transform_batch(self, logs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Transform a batch of logs."""
transformed = []
for log in logs:
t_log = self.transform_log(log)
if t_log:
transformed.append(t_log)
logger.info(f"Transformed {len(transformed)}/{len(logs)} logs")
return transformed
Load Phase (load.py)¶
from typing import Dict, List, Any, Optional
from bson import ObjectId
import logging
from bast_ai.base import Stopwatch
logger = logging.getLogger(__name__)
class MongoWarehouseLoader:
"""Loads transformed data into MongoDB warehouse."""
def __init__(self, warehouse_db):
"""
Args:
warehouse_db: MongoDB database connection to 'bast_analytics'
"""
self.db = warehouse_db
# Dimension collections
self.dim_cat = self.db['dim_cat']
self.dim_topic = self.db['dim_topic']
self.dim_model = self.db['dim_model']
self.dim_question = self.db['dim_question']
self.dim_answer = self.db['dim_answer']
self.dim_session = self.db['dim_session']
self.dim_user = self.db['dim_user']
# Fact collections
self.fact_interaction = self.db['fact_interaction']
# Metadata
self.etl_checkpoint = self.db['etl_checkpoint']
def upsert_dimension(self, collection, unique_field: str,
data: Dict[str, Any]) -> ObjectId:
"""
Insert or update dimension record using MongoDB upsert.
Args:
collection: MongoDB collection
unique_field: Field name that must be unique
data: Record data
Returns:
ObjectId of the record
"""
from datetime import datetime
# Add timestamps
data['updated_at'] = datetime.utcnow()
if 'created_at' not in data:
data['created_at'] = datetime.utcnow()
# Upsert (update if exists, insert if not)
result = collection.update_one(
{unique_field: data[unique_field]},
{'$set': data, '$setOnInsert': {'created_at': datetime.utcnow()}},
upsert=True
)
if result.upserted_id:
return result.upserted_id
else:
# Find the existing document
doc = collection.find_one({unique_field: data[unique_field]})
return doc['_id']
def load_dimensions(self, transformed_log: Dict[str, Any]) -> Dict[str, Any]:
"""
Load all dimension records for a log entry.
Returns embedded dimension objects.
Args:
transformed_log: Transformed log dictionary
Returns:
Dict with embedded dimension objects
"""
dims = {}
# Load cat dimension
cat_id = self.upsert_dimension(
self.dim_cat,
'cat_name',
{'cat_name': transformed_log['cat_name']}
)
dims['cat'] = {'_id': cat_id, 'name': transformed_log['cat_name']}
# Load topic dimension (if present)
if transformed_log.get('topic_label'):
topic_id = self.upsert_dimension(
self.dim_topic,
'topic_label',
{'topic_label': transformed_log['topic_label']}
)
dims['topic'] = {'_id': topic_id, 'label': transformed_log['topic_label']}
else:
dims['topic'] = None
# Load model dimension
model_id = self.upsert_dimension(
self.dim_model,
'model_name',
{'model_name': transformed_log['model_name']}
)
dims['model'] = {'_id': model_id, 'name': transformed_log['model_name']}
# Load persona dimension (always present, defaults to 'default_value')
persona_name = transformed_log.get('persona_name') or 'default_value'
persona_id = self.upsert_dimension(
self.dim_persona,
'persona_name',
{'persona_name': persona_name}
)
dims['persona'] = {'_id': persona_id, 'name': persona_name}
# Load question dimension (if present)
if transformed_log.get('question_text'):
question_id = self.upsert_dimension(
self.dim_question,
'question_hash',
{
'question_text': transformed_log['question_text'],
'question_hash': transformed_log['question_hash']
}
)
dims['question'] = {
'_id': question_id,
'text': transformed_log['question_text'],
'hash': transformed_log['question_hash']
}
else:
dims['question'] = None
# Load answer dimension (if present)
if transformed_log.get('answer_text'):
answer_id = self.upsert_dimension(
self.dim_answer,
'answer_hash',
{
'answer_text': transformed_log['answer_text'],
'answer_hash': transformed_log['answer_hash']
}
)
dims['answer'] = {
'_id': answer_id,
'text': transformed_log['answer_text'],
'hash': transformed_log['answer_hash']
}
else:
dims['answer'] = None
# Load session dimension (if present)
if transformed_log.get('session_id'):
session_id = self.upsert_dimension(
self.dim_session,
'session_id',
{'session_id': transformed_log['session_id']}
)
dims['session'] = {
'_id': session_id,
'session_id': transformed_log['session_id']
}
else:
dims['session'] = None
# Load user dimension (if present)
if transformed_log.get('user_id'):
user_id = self.upsert_dimension(
self.dim_user,
'user_id',
{'user_id': transformed_log['user_id']}
)
dims['user'] = {
'_id': user_id,
'user_id': transformed_log['user_id']
}
else:
dims['user'] = None
return dims
def insert_fact_interaction(self, transformed_log: Dict[str, Any],
dims: Dict[str, Any]) -> ObjectId:
"""
Insert fact_interaction record with embedded dimensions.
Args:
transformed_log: Transformed log dictionary
dims: Embedded dimension objects from load_dimensions()
Returns:
ObjectId of inserted record
"""
from datetime import datetime
# Build fact document (matches schema)
fact = {
# Time dimensions
'ts': transformed_log['ts'],
'date': transformed_log['date'],
'year': transformed_log['year'],
'month': transformed_log['month'],
'week': transformed_log['week'],
'day_of_month': transformed_log['day_of_month'],
'day_of_week': transformed_log['day_of_week'],
'hour': transformed_log['hour'],
'minute': transformed_log['minute'],
# CRITICAL: Event field (for filtering)
'event': transformed_log['event'],
# Embedded dimensions
'cat': dims['cat'],
'topic': dims['topic'],
'model': dims['model'],
'persona': dims['persona'], # NEW: Persona dimension
'question': dims['question'],
'answer': dims['answer'],
'session': dims['session'],
'user': dims['user'],
# Measures
'response_time_ms': transformed_log.get('response_time_ms'),
'token_count_input': transformed_log.get('token_count_input'),
'token_count_output': transformed_log.get('token_count_output'),
'cost_usd': transformed_log.get('cost_usd'),
'has_error': transformed_log.get('has_error', False),
'is_valid_response': True,
# Source reference
'source_log_id': transformed_log['source_log_id'],
# Metadata
'created_at': datetime.utcnow()
}
# Insert
result = self.fact_interaction.insert_one(fact)
return result.inserted_id
def load_batch(self, transformed_logs: List[Dict[str, Any]]) -> int:
"""
Load a batch of transformed logs.
Args:
transformed_logs: List of transformed log dictionaries
Returns:
Number of records loaded
"""
sw = Stopwatch()
sw.start()
loaded_count = 0
for t_log in transformed_logs:
try:
# Load dimensions
dims = self.load_dimensions(t_log)
# Insert fact
self.insert_fact_interaction(t_log, dims)
loaded_count += 1
except Exception as e:
logger.error(f"Failed to load log {t_log.get('source_log_id')}: {e}")
sw.stop()
logger.info(f"Loaded {loaded_count} records in {sw.duration:.2f}s")
return loaded_count
def update_checkpoint(self, cat_name: str, last_ts: float,
last_log_id: str):
"""Update ETL checkpoint."""
from datetime import datetime
self.etl_checkpoint.update_one(
{'cat_name': cat_name},
{
'$set': {
'last_processed_ts': last_ts,
'last_processed_log_id': last_log_id,
'last_run_time': datetime.utcnow()
}
},
upsert=True
)
Aggregation Phase (aggregate.py)¶
from datetime import datetime, date
from typing import Optional
import logging
logger = logging.getLogger(__name__)
class MongoFactAggregator:
"""Aggregates fact_interaction into summary tables using MongoDB aggregation."""
def __init__(self, warehouse_db):
self.db = warehouse_db
self.fact_interaction = self.db['fact_interaction']
self.fact_hourly_summary = self.db['fact_hourly_summary']
self.fact_daily_summary = self.db['fact_daily_summary']
def aggregate_hourly(self, target_date: Optional[date] = None):
"""
Aggregate fact_interaction into fact_hourly_summary.
Args:
target_date: Date to aggregate (default: today)
"""
if target_date is None:
target_date = date.today()
start_dt = datetime(target_date.year, target_date.month, target_date.day)
end_dt = datetime(target_date.year, target_date.month, target_date.day, 23, 59, 59)
# MongoDB aggregation pipeline
pipeline = [
# Match date range
{
'$match': {
'date': {'$gte': start_dt, '$lt': end_dt}
}
},
# Group by hour, cat, topic, model
{
'$group': {
'_id': {
'date': '$date',
'hour': '$hour',
'cat_name': '$cat.name',
'topic_label': '$topic.label',
'model_name': '$model.name'
},
'total_interactions': {'$sum': 1},
'unique_users': {'$addToSet': '$user.user_id'},
'unique_sessions': {'$addToSet': '$session.session_id'},
'unique_questions': {'$addToSet': '$question._id'},
'avg_response_time_ms': {'$avg': '$response_time_ms'},
'total_tokens': {
'$sum': {
'$add': ['$token_count_input', '$token_count_output']
}
},
'total_cost_usd': {'$sum': '$cost_usd'},
'error_count': {
'$sum': {'$cond': ['$has_error', 1, 0]}
}
}
},
# Project to clean up
{
'$project': {
'date': '$_id.date',
'year': {'$year': '$_id.date'},
'month': {'$month': '$_id.date'},
'week': {'$isoWeek': '$_id.date'},
'hour': '$_id.hour',
'cat': {
'name': '$_id.cat_name'
},
'topic': {
'label': '$_id.topic_label'
},
'model': {
'name': '$_id.model_name'
},
'total_interactions': 1,
'unique_users': {'$size': '$unique_users'},
'unique_sessions': {'$size': '$unique_sessions'},
'unique_questions': {'$size': '$unique_questions'},
'avg_response_time_ms': 1,
'total_tokens': 1,
'total_cost_usd': 1,
'error_count': 1,
'error_rate': {
'$multiply': [
{'$divide': ['$error_count', '$total_interactions']},
100
]
},
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
}
}
]
# Run aggregation
results = list(self.fact_interaction.aggregate(pipeline))
# Upsert results into fact_hourly_summary
for result in results:
self.fact_hourly_summary.update_one(
{
'cat.name': result['cat']['name'],
'topic.label': result['topic']['label'],
'model.name': result['model']['name'],
'date': result['date'],
'hour': result['hour']
},
{'$set': result},
upsert=True
)
logger.info(f"Aggregated {len(results)} hourly records for {target_date}")
def aggregate_daily(self, target_date: Optional[date] = None):
"""
Aggregate fact_interaction into fact_daily_summary.
Args:
target_date: Date to aggregate (default: today)
"""
if target_date is None:
target_date = date.today()
start_dt = datetime(target_date.year, target_date.month, target_date.day)
end_dt = datetime(target_date.year, target_date.month, target_date.day, 23, 59, 59)
# Similar pipeline to hourly, but group by day instead of hour
pipeline = [
{
'$match': {
'date': {'$gte': start_dt, '$lt': end_dt}
}
},
{
'$group': {
'_id': {
'date': '$date',
'cat_name': '$cat.name',
'topic_label': '$topic.label',
'model_name': '$model.name'
},
'total_interactions': {'$sum': 1},
'unique_users': {'$addToSet': '$user.user_id'},
'unique_sessions': {'$addToSet': '$session.session_id'},
'unique_questions': {'$addToSet': '$question._id'},
'avg_response_time_ms': {'$avg': '$response_time_ms'},
'total_tokens': {
'$sum': {
'$add': ['$token_count_input', '$token_count_output']
}
},
'total_cost_usd': {'$sum': '$cost_usd'},
'error_count': {
'$sum': {'$cond': ['$has_error', 1, 0]}
},
# For peak hour calculation
'hourly_counts': {
'$push': {
'hour': '$hour',
'count': 1
}
}
}
},
{
'$project': {
'date': '$_id.date',
'year': {'$year': '$_id.date'},
'month': {'$month': '$_id.date'},
'week': {'$isoWeek': '$_id.date'},
'day_of_month': {'$dayOfMonth': '$_id.date'},
'day_of_week': {'$isoDayOfWeek': '$_id.date'},
'cat': {'name': '$_id.cat_name'},
'topic': {'label': '$_id.topic_label'},
'model': {'name': '$_id.model_name'},
'total_interactions': 1,
'unique_users': {'$size': '$unique_users'},
'unique_sessions': {'$size': '$unique_sessions'},
'unique_questions': {'$size': '$unique_questions'},
'avg_response_time_ms': 1,
'total_tokens': 1,
'total_cost_usd': 1,
'error_count': 1,
'error_rate': {
'$multiply': [
{'$divide': ['$error_count', '$total_interactions']},
100
]
},
# Note: peak_hour calculation would require additional processing
'created_at': datetime.utcnow(),
'updated_at': datetime.utcnow()
}
}
]
results = list(self.fact_interaction.aggregate(pipeline))
# Upsert results
for result in results:
self.fact_daily_summary.update_one(
{
'cat.name': result['cat']['name'],
'topic.label': result['topic']['label'],
'model.name': result['model']['name'],
'date': result['date']
},
{'$set': result},
upsert=True
)
logger.info(f"Aggregated {len(results)} daily records for {target_date}")
API Implementation with MongoDB¶
Updated Analytics Endpoints¶
1. GET /analytics/summary¶
@router.get('/analytics/summary')
async def get_analytics_summary(
request: Request,
catname: str,
startDate: Optional[str] = None,
endDate: Optional[str] = None,
granularity: str = 'day'
) -> JSONResponse:
"""Get summary analytics from MongoDB warehouse."""
try:
# Get warehouse connection
from bast_ai_mongo import getNativePyMongo
warehouse_db = getNativePyMongo()['bast_analytics'] # Warehouse database
# Choose collection based on granularity
collection_map = {
'hour': 'fact_hourly_summary',
'day': 'fact_daily_summary',
'week': 'fact_weekly_summary',
'month': 'fact_monthly_summary'
}
collection_name = collection_map.get(granularity, 'fact_daily_summary')
collection = warehouse_db[collection_name]
# Build aggregation pipeline
pipeline = []
# Match filters
match_conditions = {'cat.name': catname}
if startDate:
start_dt = datetime.strptime(startDate, '%Y-%m-%d')
match_conditions['date'] = {'$gte': start_dt}
if endDate:
end_dt = datetime.strptime(endDate, '%Y-%m-%d')
if 'date' in match_conditions:
match_conditions['date']['$lt'] = end_dt
else:
match_conditions['date'] = {'$lt': end_dt}
pipeline.append({'$match': match_conditions})
# Group and aggregate
pipeline.append({
'$group': {
'_id': None,
'total_interactions': {'$sum': '$total_interactions'},
'unique_users': {'$max': '$unique_users'},
'unique_sessions': {'$max': '$unique_sessions'},
'avg_response_time_ms': {'$avg': '$avg_response_time_ms'},
'total_cost_usd': {'$sum': '$total_cost_usd'},
'error_rate': {'$avg': '$error_rate'}
}
})
# Execute
results = list(collection.aggregate(pipeline))
if not results:
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
'total_interactions': 0,
'unique_users': 0,
'unique_sessions': 0,
'avg_response_time_ms': 0,
'total_cost_usd': 0,
'error_rate': 0,
'period': {
'start_date': startDate,
'end_date': endDate,
'granularity': granularity
}
}
)
result = results[0]
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
'total_interactions': int(result.get('total_interactions', 0)),
'unique_users': int(result.get('unique_users', 0)),
'unique_sessions': int(result.get('unique_sessions', 0)),
'avg_response_time_ms': round(float(result.get('avg_response_time_ms', 0)), 2),
'total_cost_usd': round(float(result.get('total_cost_usd', 0)), 4),
'error_rate': round(float(result.get('error_rate', 0)), 2),
'period': {
'start_date': startDate,
'end_date': endDate,
'granularity': granularity
}
}
)
except Exception as err:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
'ERROR_CODE': 'ANALYTICS-ERR-100',
'ERROR_MSG': f'Failed to retrieve summary: {str(err)}'
}
)
2. GET /analytics/topics¶
@router.get('/analytics/topics')
async def get_topic_analytics(
request: Request,
catname: str,
startDate: Optional[str] = None,
endDate: Optional[str] = None,
granularity: str = 'day'
) -> JSONResponse:
"""Get topic breakdown from MongoDB warehouse."""
try:
from bast_ai_mongo import getNativePyMongo
warehouse_db = getNativePyMongo()['bast_analytics']
collection_map = {
'hour': 'fact_hourly_summary',
'day': 'fact_daily_summary',
'week': 'fact_weekly_summary',
'month': 'fact_monthly_summary'
}
collection = warehouse_db[collection_map.get(granularity, 'fact_daily_summary')]
# Build pipeline
pipeline = []
# Match filters
match_conditions = {'cat.name': catname}
if startDate:
start_dt = datetime.strptime(startDate, '%Y-%m-%d')
match_conditions['date'] = {'$gte': start_dt}
if endDate:
end_dt = datetime.strptime(endDate, '%Y-%m-%d')
if 'date' in match_conditions:
match_conditions['date']['$lt'] = end_dt
else:
match_conditions['date'] = {'$lt': end_dt}
pipeline.append({'$match': match_conditions})
# Calculate total interactions for percentage
pipeline.append({
'$facet': {
'totals': [
{'$group': {'_id': None, 'grand_total': {'$sum': '$total_interactions'}}}
],
'by_topic': [
{
'$group': {
'_id': '$topic.label',
'count': {'$sum': '$total_interactions'}
}
},
{'$sort': {'count': -1}}
]
}
})
# Execute
results = list(collection.aggregate(pipeline))
if not results or not results[0]['by_topic']:
return JSONResponse(status_code=status.HTTP_200_OK, content=[])
grand_total = results[0]['totals'][0]['grand_total'] if results[0]['totals'] else 1
topic_data = results[0]['by_topic']
# Format response
topics = [
{
'topic': item['_id'] if item['_id'] else '(unlabeled)',
'count': int(item['count']),
'percentage': round((item['count'] / grand_total) * 100, 2)
}
for item in topic_data
]
return JSONResponse(status_code=status.HTTP_200_OK, content=topics)
except Exception as err:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
'ERROR_CODE': 'ANALYTICS-ERR-101',
'ERROR_MSG': f'Failed to retrieve topics: {str(err)}'
}
)
3. GET /analytics/all (Combined Analytics with Selective Filtering)¶
CRITICAL: This endpoint implements selective filtering where each facet excludes its own dimension from filters. This allows users to see "breakdown by X" while filtering by Y.
Example: If user filters by topic=advising:
- total_interactions = only advising interactions (all filters applied)
- topic_counts = ALL topics with counts (topic filter excluded, but model/persona filters apply)
- model_counts = ALL models with counts (model filter excluded, but topic/persona filters apply)
- persona_counts = ALL personas with counts (persona filter excluded, but topic/model filters apply)
- questions = ALL questions (questionid filter excluded, but other filters apply)
- answers = ALL answers (answerid filter excluded, but other filters apply)
@router.get('/all')
async def get_all_analytics(
request: Request,
catname: str,
startDate: Optional[str] = None,
endDate: Optional[str] = None,
model: Optional[Union[str, List[str]]] = Query(default=None),
persona: Optional[Union[str, List[str]]] = Query(default=None),
topic: Optional[Union[str, List[str]]] = Query(default=None),
questionid: Optional[Union[str, List[str]]] = Query(default=None),
answerid: Optional[Union[str, List[str]]] = Query(default=None)
) -> JSONResponse:
"""
Get all analytics data in a single request with selective filtering.
This endpoint uses selective filtering where each facet excludes its own dimension:
- topic_counts excludes topic filters (shows all topics)
- model_counts excludes model filters (shows all models)
- persona_counts excludes persona filters (shows all personas)
- questions excludes questionid filters (shows all questions)
- answers excludes answerid filters (shows all answers)
- total_interactions includes ALL filters
"""
try:
from bast_ai_mongo import getNativePyMongo
from bast_ai.base import get_bast_config
# Get cat object to access prefix
bast_config = get_bast_config()
cat = bast_config.get_cat(catname=catname)
warehouse_db = getNativePyMongo()['bast_analytics']
# Get fact_interaction collection (or summary collections if appropriate)
fact_collection = warehouse_db['fact_interaction']
event_name = f"{cat.prefix}_api"
# Build base match (event and date only)
base_match_conditions = {'event': event_name, 'cat.name': catname}
if startDate:
start_dt = datetime.strptime(startDate, '%Y-%m-%d')
base_match_conditions['date'] = {'$gte': start_dt}
if endDate:
end_dt = datetime.strptime(endDate, '%Y-%m-%d')
if 'date' in base_match_conditions:
base_match_conditions['date']['$lt'] = end_dt
else:
base_match_conditions['date'] = {'$lt': end_dt}
# Initialize separate filters for each filter type
model_filter = None
topic_filter = None
persona_filter = None
questionid_input_text_filter = None
answerid_output_filter = None
# Process model filtering
if model is not None:
model_list = model if isinstance(model, list) else [model]
if len(model_list) > 0:
model_filter = {'$in': model_list}
# Process topic filtering
if topic is not None:
topic_list = topic if isinstance(topic, list) else [topic]
if len(topic_list) > 0:
topic_filter = {'$in': topic_list}
# Process persona filtering
if persona is not None:
persona_list = persona if isinstance(persona, list) else [persona]
if len(persona_list) > 0:
persona_filter = {'$in': persona_list}
# Process questionid filtering (resolve IDs to input_text values)
if questionid is not None:
try:
questionid_list = questionid if isinstance(questionid, list) else [questionid]
input_texts = []
for qid in questionid_list:
# Convert questionid string to ObjectId
from bson import ObjectId
question_object_id = ObjectId(qid)
# Find the document with this ID and get its input_text (from source logs)
# Note: In warehouse implementation, we'd query fact_interaction
question_doc = fact_collection.find_one({'_id': question_object_id})
if question_doc is None:
raise ValueError(f"Question with ID '{qid}' not found")
# Access nested question.text field in warehouse schema
input_text = question_doc.get('question', {}).get('text')
if input_text is None or input_text == '':
raise ValueError(f"Question with ID '{qid}' has no input_text")
input_texts.append(input_text)
# Store the filter separately
if len(input_texts) == 1:
questionid_input_text_filter = input_texts[0]
elif len(input_texts) > 1:
questionid_input_text_filter = {'$in': input_texts}
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"Invalid questionid format: {str(e)}")
# Process answerid filtering (resolve IDs to output values)
if answerid is not None:
try:
answerid_list = answerid if isinstance(answerid, list) else [answerid]
outputs = []
for aid in answerid_list:
# Convert answerid string to ObjectId
from bson import ObjectId
answer_object_id = ObjectId(aid)
# Find the document with this ID and get its output (from source logs)
# Note: In warehouse implementation, we'd query fact_interaction
answer_doc = fact_collection.find_one({'_id': answer_object_id})
if answer_doc is None:
raise ValueError(f"Answer with ID '{aid}' not found")
# Access nested answer.text field in warehouse schema
output = answer_doc.get('answer', {}).get('text')
if output is None or output == '':
raise ValueError(f"Answer with ID '{aid}' has no output")
outputs.append(output)
# Store the filter separately
if len(outputs) == 1:
answerid_output_filter = outputs[0]
elif len(outputs) > 1:
answerid_output_filter = {'$in': outputs}
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"Invalid answerid format: {str(e)}")
# Helper function to build facet-specific filters
def get_facet_match(exclude_model=False, exclude_topic=False, exclude_persona=False,
exclude_questionid=False, exclude_answerid=False):
"""
Build match conditions for a facet, excluding specified dimensions.
This is the KEY pattern for selective filtering:
- Each facet excludes its own dimension
- All other filters are still applied
"""
match = {}
if not exclude_model and model_filter:
match['model.name'] = model_filter
if not exclude_topic and topic_filter:
match['topic.label'] = topic_filter
if not exclude_persona and persona_filter:
match['persona.name'] = persona_filter
if not exclude_questionid and questionid_input_text_filter:
match['question.text'] = questionid_input_text_filter
if not exclude_answerid and answerid_output_filter:
match['answer.text'] = answerid_output_filter
return match if match else None
# Build pipeline with $facet for parallel aggregations
pipeline = []
# Add base match (event, cat, date only)
pipeline.append({'$match': base_match_conditions})
# Use $facet to run multiple aggregations in parallel
pipeline.append({
'$facet': {
# Total interactions - ALL filters (no exclusions)
'total_interactions': [
*([{'$match': m}] if (m := get_facet_match()) else []),
{'$count': 'total'}
],
# Topic counts - exclude topic filter (show all topics)
'topic_counts': [
*([{'$match': m}] if (m := get_facet_match(exclude_topic=True)) else []),
{
'$group': {
'_id': {'$ifNull': ['$topic.label', '(unlabeled)']},
'count': {'$sum': 1}
}
},
{'$project': {'_id': 0, 'topic': '$_id', 'count': 1}},
{'$sort': {'count': -1, 'topic': 1}}
],
# Model counts - exclude model filter (show all models)
'model_counts': [
*([{'$match': m}] if (m := get_facet_match(exclude_model=True)) else []),
{
'$group': {
'_id': {'$ifNull': ['$model.name', '(unlabeled)']},
'count': {'$sum': 1}
}
},
{'$project': {'_id': 0, 'model': '$_id', 'count': 1}},
{'$sort': {'count': -1, 'model': 1}}
],
# Persona counts - exclude persona filter (show all personas)
'persona_counts': [
*([{'$match': m}] if (m := get_facet_match(exclude_persona=True)) else []),
{
'$group': {
'_id': {'$ifNull': ['$persona.name', '(unlabeled)']},
'count': {'$sum': 1}
}
},
{'$project': {'_id': 0, 'persona': '$_id', 'count': 1}},
{'$sort': {'count': -1, 'persona': 1}}
],
# Questions - exclude questionid filter (show all questions)
'questions': [
*([{'$match': m}] if (m := get_facet_match(exclude_questionid=True)) else []),
{'$match': {'question.text': {'$exists': True, '$ne': None, '$ne': ''}}},
{'$sort': {'_id': -1}},
{
'$group': {
'_id': '$question.text',
'count': {'$sum': 1},
'id': {'$last': '$$ROOT.question._id'}
}
},
{'$project': {'_id': 0, 'question': '$_id', 'count': 1, 'id': 1}},
{'$sort': {'count': -1, 'question': 1}}
],
# Answers - exclude answerid filter (show all answers)
'answers': [
*([{'$match': m}] if (m := get_facet_match(exclude_answerid=True)) else []),
{'$match': {'answer.text': {'$exists': True, '$ne': None, '$ne': ''}}},
{'$sort': {'_id': -1}},
{
'$group': {
'_id': '$answer.text',
'count': {'$sum': 1},
'id': {'$last': '$$ROOT.answer._id'}
}
},
{'$project': {'_id': 0, 'answers': '$_id', 'count': 1, 'id': 1}},
{'$sort': {'count': -1, 'answers': 1}}
]
}
})
result = list(fact_collection.aggregate(pipeline))
if not result:
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
'total_interactions': 0,
'topic_counts': [],
'model_counts': [],
'persona_counts': [],
'questions': [],
'answers': []
}
)
facet_result = result[0]
# Extract and format results (same as current implementation)
# ... (extract logic)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
'total_interactions': int(total_interactions),
'topic_counts': topic_counts,
'model_counts': model_counts,
'persona_counts': persona_counts,
'questions': questions,
'answers': answers
}
)
except Exception as err:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
'ERROR_CODE': 'ANALYTICS-ERR-0',
'ERROR_MSG': f'Failed to retrieve analytics: {str(err)}'
}
)
Key Implementation Pattern:
- Use helper function get_facet_match() to build facet-specific filters
- Each facet calls get_facet_match() with its dimension excluded
- total_interactions applies all filters (no exclusions)
- This pattern enables users to filter by one dimension while seeing breakdowns by others
Example Use Case:
Results:
- total_interactions: Count of advising interactions using gpt-4o
- topic_counts: ALL topics (but counts reflect gpt-4o model filter)
- model_counts: ALL models (but counts reflect advising topic filter)
- persona_counts: ALL personas (but counts reflect advising + gpt-4o filters)
4. GET /analytics/trends¶
@router.get('/analytics/trends')
async def get_analytics_trends(
request: Request,
catname: str,
metric: str = 'interactions',
startDate: Optional[str] = None,
endDate: Optional[str] = None,
granularity: str = 'day'
) -> JSONResponse:
"""Get time-series data for charts."""
try:
from bast_ai_mongo import getNativePyMongo
warehouse_db = getNativePyMongo()['bast_analytics']
# Metric mapping
metric_map = {
'interactions': 'total_interactions',
'users': 'unique_users',
'sessions': 'unique_sessions',
'response_time': 'avg_response_time_ms',
'cost': 'total_cost_usd',
'errors': 'error_rate'
}
if metric not in metric_map:
raise ValueError(f"Invalid metric: {metric}")
metric_field = metric_map[metric]
# Choose collection
collection_map = {
'hour': 'fact_hourly_summary',
'day': 'fact_daily_summary',
'week': 'fact_weekly_summary',
'month': 'fact_monthly_summary'
}
collection = warehouse_db[collection_map.get(granularity, 'fact_daily_summary')]
# Build pipeline
pipeline = []
# Match filters
match_conditions = {'cat.name': catname}
if startDate:
start_dt = datetime.strptime(startDate, '%Y-%m-%d')
match_conditions['date'] = {'$gte': start_dt}
if endDate:
end_dt = datetime.strptime(endDate, '%Y-%m-%d')
if 'date' in match_conditions:
match_conditions['date']['$lt'] = end_dt
else:
match_conditions['date'] = {'$lt': end_dt}
pipeline.append({'$match': match_conditions})
# Group by date
if granularity == 'hour':
pipeline.append({
'$group': {
'_id': {'date': '$date', 'hour': '$hour'},
'value': {'$sum': f'${metric_field}'}
}
})
pipeline.append({'$sort': {'_id.date': 1, '_id.hour': 1}})
else:
pipeline.append({
'$group': {
'_id': '$date',
'value': {'$sum': f'${metric_field}'}
}
})
pipeline.append({'$sort': {'_id': 1}})
# Execute
results = list(collection.aggregate(pipeline))
# Format response
data = []
for item in results:
if granularity == 'hour':
date_str = f"{item['_id']['date'].strftime('%Y-%m-%d')}-{item['_id']['hour']:02d}"
else:
date_str = item['_id'].strftime('%Y-%m-%d')
data.append({
'date': date_str,
'value': float(item['value']) if item['value'] else 0
})
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
'metric': metric,
'granularity': granularity,
'data': data
}
)
except Exception as err:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
'ERROR_CODE': 'ANALYTICS-ERR-103',
'ERROR_MSG': f'Failed to retrieve trends: {str(err)}'
}
)
Performance Optimization¶
Critical Indexes¶
// fact_interaction indexes (MOST IMPORTANT)
// CRITICAL: Event filtering index (all analytics queries filter by event)
db.fact_interaction.createIndex({ "cat.name": 1, event: 1, date: 1 });
db.fact_interaction.createIndex({ event: 1, date: 1 });
// Filter-specific indexes for selective filtering pattern
db.fact_interaction.createIndex({ "cat.name": 1, event: 1, "model.name": 1, date: 1 });
db.fact_interaction.createIndex({ "cat.name": 1, event: 1, "topic.label": 1, date: 1 });
db.fact_interaction.createIndex({ "cat.name": 1, event: 1, "persona.name": 1, date: 1 });
// Compound indexes for multi-filter queries (selective filtering uses these)
db.fact_interaction.createIndex({ "cat.name": 1, event: 1, "model.name": 1, "topic.label": 1, date: 1 });
db.fact_interaction.createIndex({ "cat.name": 1, event: 1, "model.name": 1, "persona.name": 1, date: 1 });
db.fact_interaction.createIndex({ "cat.name": 1, event: 1, "topic.label": 1, "persona.name": 1, date: 1 });
// Time-based indexes
db.fact_interaction.createIndex({ date: 1, hour: 1 });
db.fact_interaction.createIndex({ year: 1, month: 1 });
db.fact_interaction.createIndex({ ts: 1 });
// Question/answer text search (for questionid/answerid filtering)
db.fact_interaction.createIndex({ "question.text": 1 });
db.fact_interaction.createIndex({ "answer.text": 1 });
// Deduplication (prevent duplicate loads)
db.fact_interaction.createIndex({ source_log_id: 1 }, { unique: true });
// fact_daily_summary indexes (same pattern with event field)
db.fact_daily_summary.createIndex({ "cat.name": 1, event: 1, date: 1 });
db.fact_daily_summary.createIndex({ "cat.name": 1, event: 1, "topic.label": 1, date: 1 });
db.fact_daily_summary.createIndex({ "cat.name": 1, event: 1, "model.name": 1, date: 1 });
db.fact_daily_summary.createIndex({ "cat.name": 1, event: 1, "persona.name": 1, date: 1 });
db.fact_daily_summary.createIndex({ date: 1 });
db.fact_daily_summary.createIndex(
{ "cat.name": 1, event: 1, "topic.label": 1, "model.name": 1, "persona.name": 1, date: 1 },
{ unique: true }
);
// Dimension indexes
db.dim_cat.createIndex({ cat_name: 1 }, { unique: true });
db.dim_topic.createIndex({ topic_label: 1 }, { unique: true });
db.dim_model.createIndex({ model_name: 1 }, { unique: true });
db.dim_persona.createIndex({ persona_name: 1 }, { unique: true }); // NEW: Persona dimension
db.dim_question.createIndex({ question_hash: 1 }, { unique: true });
db.dim_answer.createIndex({ answer_hash: 1 }, { unique: true });
// Text indexes for search
db.dim_question.createIndex({ question_text: "text" });
db.dim_answer.createIndex({ answer_text: "text" });
Sharding Strategy (for large scale)¶
// Enable sharding on warehouse database
sh.enableSharding("bast_analytics");
// Shard fact_interaction by cat.name + date (for multi-tenant scalability)
sh.shardCollection(
"bast_analytics.fact_interaction",
{ "cat.name": 1, date: 1 }
);
// Shard fact_daily_summary by cat.name + date
sh.shardCollection(
"bast_analytics.fact_daily_summary",
{ "cat.name": 1, date: 1 }
);
Pros and Cons Summary¶
MongoDB Warehouse: Pros ✅¶
- No New Infrastructure: Leverage existing MongoDB
- Operational Simplicity: Single database technology
- Team Familiarity: No learning curve
- Flexible Schema: Easy to add fields
- Good Text Search: Better for searching questions/answers
- Fast Document Retrieval: Get specific documents quickly
- Easier Deployment: No new servers to manage
MongoDB Warehouse: Cons ❌¶
- 2-5x Slower Than PostgreSQL: For analytics queries
- More Storage Required: Denormalized data (2-3x larger)
- Limited Analytics Features: No window functions, CTEs
- Complex Aggregations: Verbose pipelines
- Less Efficient Grouping: GROUP BY slower than PostgreSQL
When to Choose MongoDB¶
✅ Choose MongoDB if: - Operational simplicity is highest priority - You want to avoid adding new infrastructure - Team lacks SQL/PostgreSQL experience - 2-5x slower queries acceptable (still 10-20x faster than current) - You need strong text search - Storage cost is not a concern
When to Consider PostgreSQL¶
✅ Consider PostgreSQL if: - Maximum query performance critical - Complex analytical requirements - Storage efficiency matters - Need advanced SQL features - Willing to manage another database
Implementation Guide¶
Step 1: Create Warehouse Database¶
# Connect to MongoDB
mongosh
# Create warehouse database and collections
use bast_analytics
# Create dimension collections
db.createCollection("dim_cat")
db.createCollection("dim_topic")
db.createCollection("dim_model")
db.createCollection("dim_question")
db.createCollection("dim_answer")
db.createCollection("dim_session")
db.createCollection("dim_user")
# Create fact collections
db.createCollection("fact_interaction")
db.createCollection("fact_hourly_summary")
db.createCollection("fact_daily_summary")
db.createCollection("fact_weekly_summary")
db.createCollection("fact_monthly_summary")
# Create metadata collections
db.createCollection("etl_log")
db.createCollection("etl_checkpoint")
db.createCollection("data_quality_metrics")
Step 2: Create Indexes¶
Step 3: Implement ETL¶
Use the Python code provided above in:
- etl/extract.py
- etl/transform.py
- etl/load.py
- etl/aggregate.py
- etl/orchestrator.py
Step 4: Schedule ETL¶
# Cron job (every 15 minutes)
*/15 * * * * cd /path/to/bast-ai && pants run src/apis/analytics/etl/run_etl.py -- --job incremental --cat testcat
Step 5: Update API¶
Use the API code provided above to update your analytics endpoints.
Recommendation¶
For your use case, I recommend using MongoDB as the warehouse for the following reasons:
- ✅ Operational simplicity - You already have MongoDB infrastructure
- ✅ Faster MVP - No new database to set up
- ✅ Team familiarity - Your team knows MongoDB
- ✅ Good enough performance - Still 10-20x faster than current approach
- ✅ Migration path - Can migrate to PostgreSQL later if needed
Performance Trade-off: - PostgreSQL: 50-200ms queries - MongoDB: 150-500ms queries - Current (direct MongoDB): 5,000-30,000ms queries
Verdict: MongoDB warehouse gives you 10-20x speedup, which is excellent for MVP. If you need another 3-5x improvement later, migrate to PostgreSQL.
Next Steps: Start implementing with MongoDB warehouse using the schemas and code provided above! 🚀