Terraform module that collects Amazon Quick Suite usage logs, classifies messages with Bedrock, syncs user and QuickSight metadata, and surfaces adoption metrics through a QuickSight dashboard with 5 tabs and 80+ visuals.
Deploys a complete analytics pipeline for Amazon Quick Suite (Q Business):
- Collects three types of CloudWatch vended logs (chat, feedback, agent hours) into S3
- Classifies each chat message with Bedrock Nova 2 Lite across three dimensions: prompt category, action intent, and customer information detection
- Enriches log data through a Step Functions ETL pipeline (extracts messages, resource selections, plugin utilization, writes Parquet)
- Syncs user profiles from IAM Identity Center and QuickSight roles (configurable interval, default every 6 hours)
- Collects QuickSight metadata --- datasets, dashboards, analyses, datasources, and SPICE capacity metrics (configurable interval, default daily)
- Tracks asset creation via CloudTrail (spaces, knowledge bases, flows, agents, document uploads)
- Restricts PII-containing S3 prefixes (
AWSLogs/*,temp/*) to ETL pipeline roles only --- console and CLI users cannot access raw message text - Visualizes everything in a QuickSight dashboard with 5 tabs and 80+ visuals
Five tabs, each answering different questions:
Summary --- "What's happening right now?"
- MAU/WAU/DAU message trends
- Queries & conversations (4 KPIs + combo chart, feedback trend, scope analysis)
- Agent hours usage by subscription tier (Professional vs Enterprise)
- Hours distribution and allotment tracking
Dive Deep --- "Who's using it and how?"
- User activity by department, country, and individual
- User segmentation tiers (Power/Regular/Casual/Dormant/Churned)
- Usage patterns by hour of day, day of week
- Plugin utilization, resource selection, and feedback breakdowns
- License cost analysis (unused licenses, agent hours by user)
Asset Inventory --- "What assets exist?"
- Amazon Quick Assets: spaces, knowledge bases, flows, agents, documents (from CloudTrail)
- QuickSight BI Assets: dashboards, analyses, datasets, datasources (from QuickSight API)
- Asset relationship mappings (dashboard→dataset, dataset→datasource)
- Datasource type distribution, analysis status, orphaned datasets
Usage Insights --- "What are people asking about?"
- Prompt category distribution (13 categories: HR, Finance, IT, Data/Analytics, etc.)
- Action intent distribution (17 intents: Question Answering, Code Generation, Summarization, etc.)
- Category × Intent heatmap, category trends over time
- Customer information detection (% of messages containing PII)
- Top users by messages, top users by conversations, idle users
SPICE & Data Health --- "Is QuickSight healthy?"
- SPICE capacity utilization (% used, trend over time)
- Dataset ingestion status (completed vs failed)
- Failed ingestion details, refresh latency per dataset
- Top SPICE consumers, rows ingested vs dropped
- Datasets by import mode (SPICE vs DIRECT_QUERY)
data "aws_ssoadmin_instances" "this" {}
module "quicksuite_analytics" {
source = "github.com/aws-samples/sample-quicksuite-usage-dashboard//modules/quicksuite-analytics"
quicksight_admin_group = "qs-admins"
identity_store_id = tolist(data.aws_ssoadmin_instances.this.identity_store_ids)[0]
# Enable Bedrock message categorization
categorization_config = {
enabled = true
model_id = "global.amazon.nova-2-lite-v1:0"
bedrock_region = "us-east-1"
max_concurrency = 5
}
}The module creates all infrastructure: S3 bucket, CloudWatch log delivery, Lambda functions, Step Functions pipelines, DynamoDB config, Glue catalog, QuickSight datasets, and the full dashboard.
To pin to a specific commit (recommended for production):
source = "github.com/aws-samples/sample-quicksuite-usage-dashboard//modules/quicksuite-analytics?ref=COMMIT_SHA"- Terraform >= 1.9
- AWS provider ~> 6.0
- Active Amazon Quick Suite instance (Enterprise or Professional edition)
- IAM Identity Center configured
- QuickSight Enterprise edition with an admin group
- Docker (for building the Lambda layer at
terraform applytime, unlesslambda_layer_arnis provided) - Bedrock model access (if categorization enabled):
global.amazon.nova-2-lite-v1:0in configured region
| Variable | Type | Default | Description |
|---|---|---|---|
quicksight_admin_group |
string | required | QuickSight group for dashboard permissions (full access) |
dashboard_viewers |
object | { groups = [], users = [] } |
Groups and users to grant view-only dashboard access (names only, ARNs constructed automatically) |
identity_store_id |
string | required | IAM Identity Center Identity Store ID |
identity_store_role_arn |
string | null |
IAM role ARN in management account for cross-account Identity Store access (Control Tower setups) |
quicksight_namespace |
string | "default" |
QuickSight namespace |
s3_kms_key_arn |
string | null |
Optional KMS key for S3 encryption (AES256 if null) |
spice_enabled |
bool | true |
Enable SPICE caching (recommended for production) |
spice_refresh_interval |
string | "MINUTE15" |
Incremental SPICE refresh: MINUTE15, MINUTE30, HOURLY, DAILY |
quicksight_service_role_name |
string | "aws-quicksight-service-role-v0" |
Existing QuickSight service IAM role name |
cloudtrail_mode |
string | "new" |
"new" = create trail, "existing" = use existing bucket, "disabled" = skip asset tracking |
cloudtrail_config |
object | null |
Configuration for existing CloudTrail: { s3_bucket, org_id?, s3_prefix? }. Required when cloudtrail_mode = "existing" |
user_tier_config |
object | see below | User segmentation thresholds and percentiles |
categorization_config |
object | see below | Bedrock message categorization settings |
categorization_taxonomy_file |
string | null |
Path to custom taxonomy JSON (DynamoDB format). Uses built-in default if null |
qs_metadata_sync_interval |
string | "DAILY" |
QuickSight metadata sync: MINUTE15, MINUTE30, HOURLY, HOURS6, DAILY |
user_sync_interval |
string | "HOURS6" |
User sync frequency: MINUTE15, MINUTE30, HOURLY, HOURS6, DAILY |
qs_metadata_lambda_memory |
number | 256 |
Memory (MB) for metadata collectors. Increase for 10K+ datasets |
lambda_layer_arn |
string | null |
ARN of a pre-built Lambda layer (boto3 + pyarrow). Skips Docker build when provided. Format: arn:aws:lambda:REGION:ACCOUNT:layer:LAYER_NAME:VERSION |
To share the dashboard with additional groups or users (view-only), pass their QuickSight principal ARNs:
dashboard_viewers = {
groups = ["managers", "quicksuite_dashboardviewer"]
users = ["john.doe"]
}Just pass group names and usernames --- the module constructs the full QuickSight ARNs automatically using your account ID, region, and namespace.
Viewers can see all visuals, interact with filters, and drill down. They cannot edit, delete, or create analyses from the dashboard. Dataset permissions are not required for viewers --- QuickSight executes queries using the dashboard owner's permissions.
Using IAM Identity Center groups: If you manage users through IAM Identity Center (SSO), your IDC groups must be synced to QuickSight groups first. Check existing groups in the QuickSight console (Manage QuickSight → Manage groups) or via CLI:
aws quicksight list-groups --aws-account-id ACCOUNT_ID --namespace defaultThe QuickSight group ARN format is arn:aws:quicksight:REGION:ACCOUNT:group/NAMESPACE/GROUP_NAME. If your IDC group doesn't appear in QuickSight, add it via the QuickSight console or aws quicksight create-group.
The quicksight_service_role_name must be the IAM role that QuickSight uses in your account. The module automatically attaches the required S3 read permissions to this role --- you do not need to configure permissions manually.
To find your role name, go to the QuickSight console: Manage QuickSight → Security & permissions → QuickSight access to AWS services. Common names include aws-quicksight-service-role-v0 (default), aws-quicksight-s3-consumers-role-v0, or a custom name your organization created.
The module attaches these inline policies to the role:
QuickSightS3Access-quicksuite--- read access to the quicksuite-logs S3 bucket (+ KMS decrypt ifs3_kms_key_arnis set)QuickSightCloudTrailAccess-quicksuite--- read access to the CloudTrail S3 bucket (only whencloudtrail_mode = "existing")
If you provide a customer-managed KMS key via s3_kms_key_arn, the module configures the S3 bucket to use it and grants all Lambda/SFN roles kms:Decrypt, kms:GenerateDataKey, and kms:DescribeKey via IAM policies.
However, you must also update the KMS key policy to allow the CloudWatch log delivery service to write encrypted logs. Without this, vended logs silently fail to deliver to S3. Add this statement to your key policy:
{
"Sid": "AllowCloudWatchLogsDelivery",
"Effect": "Allow",
"Principal": {
"Service": "delivery.logs.amazonaws.com"
},
"Action": [
"kms:GenerateDataKey*",
"kms:Encrypt",
"kms:Decrypt",
"kms:DescribeKey"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:SourceAccount": "YOUR_ACCOUNT_ID"
}
}
}This can be added in the KMS console (KMS → Customer managed keys → select key → Key policy → Edit) or via CLI:
aws kms get-key-policy --key-id YOUR_KEY_ARN --policy-name default --output text > /tmp/kms-policy.json
# Edit /tmp/kms-policy.json to add the statement above
aws kms put-key-policy --key-id YOUR_KEY_ARN --policy-name default --policy file:///tmp/kms-policy.jsonIf you don't need KMS encryption, leave s3_kms_key_arn = null (default) --- the bucket will use AES256 (SSE-S3) and no key policy changes are needed.
If IAM Identity Center is in a different account (common with AWS Control Tower), the user sync Lambdas need to assume a role in the management account to access the Identity Store. Set identity_store_role_arn to a role in the management account:
identity_store_id = "d-xxxxxxxxxx"
identity_store_role_arn = "arn:aws:iam::MANAGEMENT_ACCOUNT:role/quicksuite-idc-reader"After deploying, run terraform output identity_store_cross_account_role_policy to get the pre-filled policies. Or manually create a role in the management account (e.g., quicksuite-idc-reader) with these two policies:
Trust policy (replace MEMBER_ACCOUNT_ID with the account where the module is deployed):
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::MEMBER_ACCOUNT_ID:role/quicksuite-lambda-user-sync-role"
},
"Action": "sts:AssumeRole"
}]
}Permissions policy:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"identitystore:ListUsers",
"identitystore:DescribeUser"
],
"Resource": "*"
}]
}Without this, the user sync will fail with AccessDeniedException when calling Identity Store APIs from the member account.
If Docker is not available in your deployment environment (e.g., CI/CD pipelines), you can pre-build the layer externally and pass its ARN:
lambda_layer_arn = "arn:aws:lambda:eu-west-1:123456789012:layer:quicksuite-dependencies:1"The layer must contain boto3 and pyarrow for Python 3.14 ARM64. Build it using the Dockerfile in modules/quicksuite-analytics/lambda/dependencies_layer/:
cd modules/quicksuite-analytics/lambda/dependencies_layer
docker build --platform linux/arm64 -t quicksuite-layer .
container_id=$(docker create quicksuite-layer)
docker cp $container_id:/opt/python ./python && docker rm $container_id
zip -r layer.zip python && rm -rf python
aws lambda publish-layer-version \
--layer-name quicksuite-dependencies \
--zip-file fileb://layer.zip \
--compatible-runtimes python3.14 \
--compatible-architectures arm64 \
--region eu-west-1Use the returned LayerVersionArn as the lambda_layer_arn value.
When enabled, each chat message is classified by Bedrock across three dimensions: prompt category (13 options), action intent (17 options), and whether it contains customer information. The taxonomy is configurable --- provide a custom JSON file via categorization_taxonomy_file or use the built-in default (81 few-shot examples).
categorization_config = {
enabled = true
model_id = "global.amazon.nova-2-lite-v1:0"
bedrock_region = "us-east-1" # Region where Bedrock model is accessible
max_concurrency = 5 # Distributed Map concurrency (increase to 20-50 for large orgs)
}When disabled (default), messages are tagged "Uncategorized" and no Bedrock calls are made.
Users are classified into tiers using a threshold + percentile model. The threshold is the minimum message count to qualify. The percentile determines actual placement among qualifying users.
user_tier_config = {
power_min_messages = 300 # minimum monthly messages to qualify for Power
regular_min_messages = 150 # minimum for Regular
casual_min_messages = 1 # minimum for Casual
power_percentile = 10 # top 10% of qualifying users = Power
regular_percentile = 30 # next 30% = Regular
dormant_days = 30 # no activity = Dormant
churned_days = 60 # no activity = Churned
}If your organization uses a central logging account (common with AWS Control Tower), set cloudtrail_mode = "existing" and provide the bucket name:
Same-account, existing trail:
cloudtrail_mode = "existing"
cloudtrail_config = {
s3_bucket = "my-cloudtrail-bucket"
}Cross-account (e.g., Control Tower log archive):
cloudtrail_mode = "existing"
cloudtrail_config = {
s3_bucket = "aws-controltower-logs-LOGACCOUNT-eu-west-1"
}After deploying, retrieve the bucket policy statement the logging account admin needs to add:
terraform output -raw cloudtrail_cross_account_bucket_policy | jq .This outputs a ready-to-use JSON policy statement with your account ID, QuickSight role, and bucket pre-filled. The logging account admin adds this as a statement in their bucket policy.
If the bucket uses a customer-managed KMS key, the logging account must also grant decrypt access:
{
"Sid": "AllowQuickSuiteDecrypt",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::QUICKSUITE_ACCOUNT:role/QUICKSIGHT_SERVICE_ROLE"
},
"Action": "kms:Decrypt",
"Resource": "*"
}Organization trail (org ID in S3 path):
cloudtrail_mode = "existing"
cloudtrail_config = {
s3_bucket = "central-logging-bucket"
org_id = "o-abc123def4"
}Custom prefix (non-standard path):
cloudtrail_mode = "existing"
cloudtrail_config = {
s3_bucket = "central-logging-bucket"
s3_prefix = "custom/AWSLogs/o-abc123/123456789012/CloudTrail/eu-west-1"
}CloudWatch Vended Logs (CHAT, FEEDBACK, AGENT_HOURS)
--> S3 bucket (PII prefixes restricted to pipeline roles)
--> S3 event notification --> Lambda trigger --> Step Functions
|
+--> Parse (extract 3 log types + resource selections + plugins)
+--> Categorize (Bedrock Nova 2 Lite, Distributed Map per message)
+--> Write Parquet (enriched/, enriched_agent_hours/, enriched_feedback/,
| enriched_resource_selections/, enriched_plugins/)
|
v
Glue tables (partition projection, zero partition management)
|
v
Athena --> QuickSight datasets (SPICE) --> Dashboard (5 tabs, 80+ visuals)
IAM Identity Center + QuickSight API
--> User sync SFN (configurable interval, Distributed Map for 10k+ users)
--> user_attributes/users.jsonl (JSONL with tier computation)
QuickSight API + CloudWatch
--> QS metadata sync SFN (configurable interval, 3 Distributed Map branches)
--> qs_metadata/ (datasets, dashboards, analyses, datasources, SPICE capacity)
CloudTrail (optional)
--> S3 bucket (same bucket, /CloudTrail/ prefix)
--> Glue table (partition projection) --> QuickSight dataset
Bedrock categorization config
--> DynamoDB (taxonomy: categories, intents, few-shot examples)
--> Configurable via Terraform variable or custom JSON file
| Data Type | S3 Prefix | Format | Partitions |
|---|---|---|---|
| Chat messages (enriched) | enriched/ |
Parquet | year/month/day |
| Agent hours | enriched_agent_hours/ |
Parquet | year/month/day |
| Feedback | enriched_feedback/ |
Parquet | year/month/day |
| Resource selections | enriched_resource_selections/ |
Parquet | year/month/day |
| Plugin utilization | enriched_plugins/ |
Parquet | year/month/day |
| User attributes | user_attributes/ |
JSONL | none (overwrite) |
| QS datasets metadata | qs_metadata/datasets/ |
JSONL | none (overwrite) |
| QS dashboards metadata | qs_metadata/dashboards/ |
JSONL | none (overwrite) |
| QS analyses metadata | qs_metadata/analyses/ |
JSONL | none (overwrite) |
| QS datasources metadata | qs_metadata/datasources/ |
JSONL | none (overwrite) |
| SPICE capacity metrics | qs_metadata/spice_capacity/ |
Parquet | year/month/day |
| CloudTrail events | Raw JSON (queried directly) | JSON | year/month/day |
All Glue tables use partition projection --- Athena computes partitions from the S3 path pattern, so there is no Lambda, DynamoDB, or Athena DDL needed for partition discovery.
| Function | Purpose | Runtime |
|---|---|---|
quicksuite-sfn-trigger |
S3 event --> start Step Functions | Python 3.14 ARM64 |
quicksuite-log-parser |
Extract 3 log types, derive query_scope, extract resources + plugins | Python 3.14 ARM64 |
quicksuite-message-categorizer |
Classify messages via Bedrock (enabled) or inject defaults (disabled) | Python 3.14 ARM64 |
quicksuite-result-writer |
Write Parquet, handle Distributed Map results | Python 3.14 ARM64 |
quicksuite-user-idc-list |
List IAM Identity Center users | Python 3.14 ARM64 |
quicksuite-user-idc-describe |
Describe user with enterprise extension | Python 3.14 ARM64 |
quicksuite-user-qs-list |
List QuickSight users with roles | Python 3.14 ARM64 |
quicksuite-user-merge |
Merge IDC + QS data, derive license type, compute user tiers | Python 3.14 ARM64 |
quicksuite-qs-list-datasets |
Paginate QuickSight list_data_sets | Python 3.14 ARM64 |
quicksuite-qs-describe-dataset |
Per-dataset: describe + permissions + ingestions | Python 3.14 ARM64 |
quicksuite-qs-list-dashboards |
Paginate QuickSight list_dashboards | Python 3.14 ARM64 |
quicksuite-qs-describe-dashboard |
Per-dashboard: describe + dataset mappings | Python 3.14 ARM64 |
quicksuite-qs-list-analyses |
Paginate QuickSight list_analyses | Python 3.14 ARM64 |
quicksuite-qs-describe-analysis |
Per-analysis: describe + dataset mappings | Python 3.14 ARM64 |
quicksuite-qs-write-metadata |
Read Distributed Map results, write final JSONL | Python 3.14 ARM64 |
quicksuite-qs-collect-datasources |
Discover datasources + dataset mappings | Python 3.14 ARM64 |
quicksuite-qs-collect-spice |
CloudWatch SPICE capacity metrics --> Parquet | Python 3.14 ARM64 |
The S3 bucket policy includes explicit Deny statements on PII-containing prefixes (AWSLogs/* and temp/*). Only the ETL pipeline IAM roles (Lambda + SFN) are allowlisted via aws:PrincipalArn condition. Console users, CLI users, and any non-pipeline roles receive AccessDenied when attempting to read or list raw message data. CloudWatch log delivery (s3:PutObject) is unaffected.
| Output | Description |
|---|---|
bucket_name |
S3 bucket name |
bucket_arn |
S3 bucket ARN |
dashboard_id |
QuickSight dashboard ID |
user_sync_sfn_arn |
User sync state machine ARN |
cloudtrail_cross_account_bucket_policy |
Ready-to-use bucket policy JSON for cross-account CloudTrail (null when not applicable) |
identity_store_cross_account_role_policy |
Trust + permissions policies for management account IDC role (null when not applicable) |
git clone <repo>
cd amazon-quicksuite-dashboard-v2
# Edit main.tf with your profile/region/admin group
terraform init
terraform plan
terraform apply
# Run unit tests (69 tests)
make test
# Validate Terraform
make validate
# Build Lambda layer (requires Docker)
make build-layer
# Full check (validate + test + lint)
make check
# Trigger user sync manually
aws stepfunctions start-execution \
--state-machine-arn "$(terraform output -raw user_sync_sfn_arn)" \
--input '{"source": "manual"}' \
--profile your-profile --region eu-west-1
# Trigger SPICE refresh
aws quicksight create-ingestion \
--aws-account-id $(aws sts get-caller-identity --query Account --output text) \
--data-set-id quicksuite-messages \
--ingestion-id "manual-$(date +%s)" \
--ingestion-type FULL_REFRESH \
--profile your-profile --region eu-west-1main.tf # Local dev entry point
modules/quicksuite-analytics/ # The distributable module
lambda/ # 17 Lambda functions
config/ # Default categorization taxonomy JSON
*.tf # ~34 Terraform files (service-prefixed)
test/unit/ # 69 unit tests (pytest)
docs/superpowers/specs/ # Design specs (Features 1-7)
docs/superpowers/plans/ # Implementation plans
Files follow a service-prefix convention:
| Prefix | Contents |
|---|---|
lambda_*.tf |
Lambda functions and layers |
sfn_*.tf |
Step Functions state machines |
iam_*.tf |
IAM roles and policies |
eventbridge_*.tf |
EventBridge rules and targets |
glue_*.tf |
Glue catalog tables |
quicksight_*.tf |
QuickSight resources (datasets, dashboard) |
dynamodb.tf |
Categorizer config table |
cloudtrail.tf |
CloudTrail trail |
cloudwatch_delivery.tf |
CloudWatch log delivery |
s3.tf |
S3 bucket + PII access restrictions |
- Agent names not resolved --- custom agent UUIDs display as-is (no
list_agentsAPI).SYSTEMis labeled "Default Assistant". - Space names not resolved --- only space UUIDs are captured. No
list_spacesAPI exists. - No idempotency on reprocessing --- re-running ETL on existing log files creates duplicate Parquet files. Clean the
enriched/prefix before reprocessing. - User tier requires Athena --- the tier computation queries Athena during user sync. If Athena is unavailable, all users get tier "Unknown".
- CloudTrail historical gap --- only captures events from trail creation onward. Pre-existing assets appear when updated.
- Idle users table uses fixed 90-day window --- the idle users table on Usage Insights always shows users with no messages in the last 90 days, regardless of the date filter selection.
- SPICE capacity metrics --- CloudWatch
AWS/QuickSightmetrics may take time to populate for new accounts. - Bedrock response parsing --- Nova 2 Lite wraps JSON responses in markdown code fences. The categorizer strips these, but other models may need adaptation.
Apache-2.0