Real-time clickstream ingestion, stream processing, and analytics on AWS — handling 10K–100K concurrent users with zero-downtime auto-scaling.
- Architecture Overview
- Tech Stack
- Why Each Component
- AWS Infrastructure
- Local Setup
- Running the Pipeline
- Auto-Scaling Deep Dive
- Spark Streaming Job
- Airflow ETL Pipeline
- OpenSearch & Kibana Dashboard
- Redshift Analytics
- Load Testing with Locust
- All Commands Reference
- Cost Considerations
- Portfolio Notes
Locust (10K–100K users)
│
▼ HTTP POST /ingest
AWS ALB (Application Load Balancer)
│
▼ distributes traffic
ECS Fargate (FastAPI, 2–10 tasks, auto-scales)
│
▼ SSL port 9094
AWS MSK (Kafka, 3 brokers, topic: clickstream)
│
▼ subscribe
EMR Serverless (Spark Streaming)
├──► OpenSearch (5s micro-batch) ──► Kibana Dashboard
└──► S3 Parquet (30s batch)
│
▼ hourly
Airflow DAG (ETL: sessionize + score)
│
▼
Redshift Serverless (clickstream_sessions table)
Data volume: 107,811 requests / 2 minutes @ 10K users, 0 failures after auto-scaling to 10 tasks.
| Layer | Technology | Why |
|---|---|---|
| Load generation | Locust | Python-native, realistic user simulation |
| Ingestion API | FastAPI + Gunicorn | Async, fire-and-forget Kafka writes |
| Container orchestration | ECS Fargate | Serverless containers, no EC2 management |
| Load balancer | AWS ALB | HTTP/HTTPS, request-count-based auto-scaling |
| Message broker | AWS MSK (Kafka) | Durable, replayable, 3-broker HA |
| Stream processing | EMR Serverless Spark | Managed Spark, no cluster ops |
| Object storage | Amazon S3 | Cheap, durable Parquet landing zone |
| Search & analytics | OpenSearch + Kibana | Real-time dashboards, 5s refresh |
| Workflow orchestration | Apache Airflow | DAG-based ETL, hourly scheduling |
| Data warehouse | Redshift Serverless | SQL analytics, sessionized data |
ECS Fargate runs Docker containers without you managing servers. Compared to alternatives:
- vs EC2: No patching, no capacity planning. You define CPU/memory per task; AWS handles the rest.
- vs Lambda: FastAPI with persistent Kafka connections is not a good fit for Lambda's stateless ephemeral model. Fargate keeps the connection pool alive across requests.
- vs ECS on EC2: Fargate charges per task-second. For bursty traffic (0 → 10K users), you only pay when tasks are running.
Each Fargate task runs Gunicorn with 16 workers, handling ~700 concurrent requests. At 10K users, ALB distributes across 10 tasks = 160 workers total.
ALB operates at Layer 7 (HTTP). This matters because:
- ALB can route based on path (
/ingest), headers, and query strings. - ALB exposes
RequestCountPerTarget— the exact metric used for our auto-scaling policy. NLB works at Layer 4 (TCP) and doesn't expose this metric. - API Gateway adds latency and cost per million requests. ALB is flat-rate and faster for high-throughput ingest.
Kafka decouples ingestion from processing. Without Kafka:
- If Spark is slow or down, events are lost.
- You can't replay events for reprocessing or debugging.
- Multiple consumers (OpenSearch + S3) would require the FastAPI app to write to both — tight coupling.
MSK gives us a managed 3-broker cluster with SSL, replication factor 3 — every message is written to 3 brokers before ack. Even if one broker dies, no data loss.
Spark Structured Streaming is the industry standard for large-scale stream processing. EMR Serverless means:
- No cluster to manage — submit a job, AWS provisions workers automatically.
- VPC-native — can reach MSK brokers on private subnets.
--execution-timeout-minutes 0means the job runs forever (streaming).
Kinesis Data Analytics (Flink) is an alternative but requires learning Flink APIs. Spark SQL is more familiar for analytics engineers.
Redshift Serverless is the right choice for:
- Historical session queries — "show me sessions where engagement > 0.8 last week". Athena on S3 works too but is slower for repeated queries on the same data.
- SQL compatibility — standard SQL, JDBC connection, works with any BI tool.
- Serverless — no cluster to manage, charges per RPU-hour of actual query time.
Athena is better if you never re-query the same data. Redshift is better for dashboards and recurring analytical queries.
Airflow gives you a visual DAG, retry logic, SLA monitoring, and a rich operator ecosystem. Our ETL DAG:
- Reads Parquet from S3
- Sessionizes events (30-minute inactivity window)
- Scores engagement (page views, add-to-cart, purchase events weighted)
- Writes sessions to Redshift via JDBC
AWS Glue would work but costs more per DPU-hour and has less flexibility. Step Functions is better for microservice orchestration, not data pipelines.
| Resource | Name / ID | Region |
|---|---|---|
| S3 Bucket | clickstream-pipeline |
us-east-1 |
| MSK Cluster | clickstream-kafka |
us-east-1 |
| MSK Brokers | b-1,b-2,b-3.clickstreamkafka.0luxcu.c6.kafka.us-east-1.amazonaws.com:9094 |
us-east-1 |
| ECS Cluster | clickstream-cluster |
us-east-1 |
| ECS Service | clickstream-fastapi-service |
us-east-1 |
| ECR Repo | 155409188308.dkr.ecr.us-east-1.amazonaws.com/clickstream-fastapi |
us-east-1 |
| ALB | clickstream-alb-1631464060.us-east-1.elb.amazonaws.com |
us-east-1 |
| OpenSearch | search-clickstream-j3glehr2glclhpparfwtmi5mte.us-east-1.es.amazonaws.com |
us-east-1 |
| Redshift Serverless | clickstream-workgroup |
ap-southeast-2 |
| EMR Serverless App | clickstream-spark (ID: 00g687l6tffeq309) |
us-east-1 |
| VPC | vpc-0396339c912ba6fec |
us-east-1 |
clickstream-ecs-role— ECS task role (S3, MSK, CloudWatch)clickstream-emr-role— EMR Serverless execution role (S3, MSK, OpenSearch, Redshift)clickstream-redshift-role— Redshift S3 access
- Python 3.12
- Docker
- AWS CLI configured
- Java 11+ (for local Kafka)
git clone https://github.com/shan1322/clickstream-data-pipeline.git
cd clickstream-data-pipeline
python -m venv venv
source venv/bin/activate
pip install -e ".[api,simulator,etl,orchestration]"Copy .env and fill in values:
cp .env.example .envKey variables:
# Local Kafka (for local dev)
KAFKA_BROKER=localhost:9092
KAFKA_TOPIC=clickstream
# AWS MSK (for production)
MSK_BROKER=b-1.clickstreamkafka...amazonaws.com:9094,b-2...,b-3...
# S3
S3_BUCKET=s3://clickstream-pipeline/parquet/
# OpenSearch
OPENSEARCH_HOST=search-clickstream-...es.amazonaws.com
OPENSEARCH_PORT=443
OPENSEARCH_USER=admin
OPENSEARCH_PASSWORD=<your-password>
# Redshift
REDSHIFT_HOST=clickstream-workgroup....redshift-serverless.amazonaws.com
REDSHIFT_PORT=5439
REDSHIFT_DB=dev
REDSHIFT_USER=admin
REDSHIFT_PASSWORD=<your-password>
# EMR
EMR_APPLICATION_ID=00g687l6tffeq309
AWS_DEFAULT_REGION=us-east-1# Start local Kafka
./start_local.sh
# Start FastAPI locally
cd fastapi_app
uvicorn main:app --reload --port 8000# Build and push Docker image
docker build -t clickstream-fastapi ./fastapi_app/
docker tag clickstream-fastapi:latest 155409188308.dkr.ecr.us-east-1.amazonaws.com/clickstream-fastapi:latest
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 155409188308.dkr.ecr.us-east-1.amazonaws.com
docker push 155409188308.dkr.ecr.us-east-1.amazonaws.com/clickstream-fastapi:latest
# Force new deployment
aws ecs update-service \
--cluster clickstream-cluster \
--service clickstream-fastapi-service \
--force-new-deployment \
--region us-east-1set -a; source .env; set +a
aws emr-serverless start-job-run \
--application-id 00g687l6tffeq309 \
--execution-role-arn arn:aws:iam::155409188308:role/clickstream-emr-role \
--execution-timeout-minutes 0 \
--job-driver "{
\"sparkSubmit\": {
\"entryPoint\": \"s3://clickstream-pipeline/scripts/spark_streaming.py\",
\"entryPointArguments\": [
\"KAFKA_BROKER=$MSK_BROKER\",
\"KAFKA_TOPIC=clickstream\",
\"S3_BUCKET=s3://clickstream-pipeline/parquet/\",
\"AWS_DEFAULT_REGION=us-east-1\",
\"OPENSEARCH_HOST=$OPENSEARCH_HOST\",
\"OPENSEARCH_PORT=$OPENSEARCH_PORT\",
\"OPENSEARCH_USER=$OPENSEARCH_USER\",
\"OPENSEARCH_PASSWORD=$OPENSEARCH_PASSWORD\"
],
\"sparkSubmitParameters\": \"--jars s3://clickstream-pipeline/jars/opensearch-spark-30_2.12-1.1.0.jar,s3://clickstream-pipeline/jars/spark-sql-kafka-0-10_2.12-3.4.1.jar,s3://clickstream-pipeline/jars/spark-token-provider-kafka-0-10_2.12-3.4.1.jar,s3://clickstream-pipeline/jars/kafka-clients-3.3.2.jar,s3://clickstream-pipeline/jars/commons-pool2-2.11.1.jar\"
}
}" \
--configuration-overrides '{
"monitoringConfiguration": {
"s3MonitoringConfiguration": {
"logUri": "s3://clickstream-pipeline/logs/"
}
}
}' \
--region us-east-1aws emr-serverless list-job-runs \
--application-id 00g687l6tffeq309 \
--region us-east-1 \
--query "jobRuns[0].{state:state,details:stateDetails}"JOB_ID=$(aws emr-serverless list-job-runs \
--application-id 00g687l6tffeq309 \
--region us-east-1 \
--query "jobRuns[0].id" --output text)
aws s3 cp s3://clickstream-pipeline/logs/applications/00g687l6tffeq309/jobs/$JOB_ID/SPARK_DRIVER/stderr.gz - | gunzip | tail -50aws emr-serverless cancel-job-run \
--application-id 00g687l6tffeq309 \
--job-run-id <JOB_RUN_ID> \
--region us-east-1cd simulator
locust -f traffic_simulator.py \
--host=http://clickstream-alb-1631464060.us-east-1.elb.amazonaws.com \
--users 10000 \
--spawn-rate 100 \
--run-time 120s \
--headless# Start Airflow
export AIRFLOW_HOME=./airflow
airflow db init
airflow webserver --port 8080 &
airflow scheduler &
# Trigger DAG manually
airflow dags trigger clickstream_etlAuto-scaling is configured on the ECS service using ALB RequestCountPerTarget instead of CPU.
Why not CPU? FastAPI with Gunicorn is I/O bound — writing to Kafka is network I/O, not CPU computation. Under 10,000 concurrent users, CPU stays at ~4% while threads exhaust. CPU-based scaling would never trigger.
Why RequestCountPerTarget? When requests-per-task exceed 1,000, ALB signals ECS to add tasks. This directly measures actual load on the application.
aws application-autoscaling put-scaling-policy \
--service-namespace ecs \
--resource-id service/clickstream-cluster/clickstream-fastapi-service \
--scalable-dimension ecs:service:DesiredCount \
--policy-name clickstream-request-scaling \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration '{
"TargetValue": 1000.0,
"PredefinedMetricSpecification": {
"PredefinedMetricType": "ALBRequestCountPerTarget",
"ResourceLabel": "app/clickstream-alb/871aeef0a5f3b4fb/targetgroup/clickstream-tg/4997184546af1884"
},
"ScaleInCooldown": 300,
"ScaleOutCooldown": 60
}' \
--region us-east-1| Users | Tasks | Req/s | Failures |
|---|---|---|---|
| 0 | 2 (min) | 0 | 0 |
| 10,000 burst | 2 → 10 | ~700 | 149 (during scale-up) |
| 10,000 steady | 10 | ~700 | 0 |
Scale-out cooldown is 60 seconds — new tasks start in ~90 seconds from trigger. Scale-in cooldown is 300 seconds to avoid thrashing.
aws ecs describe-services \
--cluster clickstream-cluster \
--services clickstream-fastapi-service \
--region us-east-1 \
--query "services[0].{running:runningCount,desired:desiredCount}"watch -n 10 "aws ecs describe-services \
--cluster clickstream-cluster \
--services clickstream-fastapi-service \
--region us-east-1 \
--query 'services[0].{running:runningCount,desired:desiredCount}'"spark/spark_streaming.py reads from MSK Kafka topic clickstream and writes to two sinks in parallel:
Sink 1 — S3 (batch every 30 seconds):
- Parquet format, partitioned by
event_type - Checkpoints at
s3a://clickstream-pipeline/parquet/_checkpoint - Used by Airflow for hourly ETL to Redshift
Sink 2 — OpenSearch (micro-batch every 5 seconds):
- Index:
clickstream - Used by Kibana for live dashboards
- Checkpoints at
s3a://clickstream-pipeline/parquet/_os_checkpoint
schema = StructType([
StructField("user_id", IntegerType()),
StructField("item_id", IntegerType()),
StructField("event_type", StringType()), # page_view, add_to_cart, purchase
StructField("timestamp", LongType()),
StructField("session_id", StringType())
])All pre-downloaded to s3://clickstream-pipeline/jars/:
| Jar | Version | Purpose |
|---|---|---|
spark-sql-kafka-0-10_2.12 |
3.4.1 | Kafka source connector |
spark-token-provider-kafka-0-10_2.12 |
3.4.1 | Kafka auth tokens |
kafka-clients |
3.3.2 | Kafka client library |
commons-pool2 |
2.11.1 | Connection pooling for Kafka |
opensearch-spark-30_2.12 |
1.1.0 | OpenSearch sink connector |
Why pre-download jars? EMR Serverless runs in a VPC with no internet access. --packages downloads from Maven Central which is unreachable. Pre-staging to S3 and using --jars is the correct pattern.
Schedule: Hourly (0 * * * *)
Tasks:
run_spark_etl— submitsetl/etl_job.pyto Spark (local mode)verify_redshift— checks row count inclickstream_sessions
etl/etl_job.py reads Parquet from S3, applies sessionization, and writes to Redshift:
Sessionization logic:
- Events within a 30-minute inactivity window belong to the same session
- Session boundary = gap > 30 minutes between events for same
user_id
Engagement scoring:
# Weighted event scoring
engagement = (
page_views * 1.0 +
add_to_cart * 3.0 +
purchases * 10.0
) / session_duration_minutesOutput schema in Redshift (clickstream_sessions):
CREATE TABLE clickstream_sessions (
session_id VARCHAR(36),
user_id INTEGER,
session_start TIMESTAMP,
session_end TIMESTAMP,
session_duration_seconds FLOAT,
view_count INTEGER,
addtocart_count INTEGER,
transaction_count INTEGER,
total_events INTEGER
);airflow dags trigger clickstream_etlaws redshift-data execute-statement \
--workgroup-name clickstream-workgroup \
--database dev \
--sql "SELECT COUNT(*) FROM clickstream_sessions" \
--region ap-southeast-2- Visual DAG in UI — see which tasks passed/failed at a glance
- Automatic retries on failure (configurable per task)
- SLA alerts — know when an hourly job is late
- Backfill — if the pipeline was down, re-run historical hours with one command
# Backfill missed runs
airflow dags backfill clickstream_etl \
--start-date 2026-06-01 \
--end-date 2026-06-06- Name:
clickstream - Documents: 153,826+ (grows with every Locust run)
- Refresh: Kibana dashboard set to auto-refresh every 5 seconds
curl -u "admin:<password>" \
"https://search-clickstream-j3glehr2glclhpparfwtmi5mte.us-east-1.es.amazonaws.com/clickstream/_count"curl -u "admin:<password>" \
"https://search-clickstream-j3glehr2glclhpparfwtmi5mte.us-east-1.es.amazonaws.com/_cluster/health?pretty"- Event type breakdown — pie chart of
page_view/add_to_cart/purchase - Events over time — line chart, 10-second buckets
- Unique users — cardinality aggregation on
user_id - Top items — bar chart of most-viewed
item_id
SELECT COUNT(*) FROM clickstream_sessions;
-- 17,907 sessions after first ETL run-- Top 10 users by purchases
SELECT user_id,
COUNT(*) as total_sessions,
AVG(session_duration_seconds) as avg_duration,
SUM(transaction_count) as total_purchases,
SUM(addtocart_count) as total_atc,
SUM(view_count) as total_views
FROM clickstream_sessions
GROUP BY user_id
ORDER BY total_purchases DESC
LIMIT 10;
-- Session duration distribution
SELECT
CASE
WHEN session_duration_seconds < 60 THEN '< 1 min'
WHEN session_duration_seconds < 300 THEN '1-5 min'
WHEN session_duration_seconds < 1800 THEN '5-30 min'
ELSE '> 30 min'
END AS bucket,
COUNT(*) as sessions
FROM clickstream_sessions
GROUP BY 1
ORDER BY 2 DESC;
-- Conversion funnel
SELECT
SUM(view_count) as total_views,
SUM(addtocart_count) as total_atc,
SUM(transaction_count) as total_purchases,
ROUND(100.0 * SUM(transaction_count) / NULLIF(SUM(view_count), 0), 2) as conversion_rate
FROM clickstream_sessions;cd simulator
locust -f traffic_simulator.py \
--host=http://clickstream-alb-1631464060.us-east-1.elb.amazonaws.com \
--users 10000 \
--spawn-rate 100 \
--run-time 120s \
--headlesscd simulator
locust -f traffic_simulator.py \
--host=http://clickstream-alb-1631464060.us-east-1.elb.amazonaws.com
# Open http://localhost:8089# Master
locust -f traffic_simulator.py \
--host=http://clickstream-alb-1631464060.us-east-1.elb.amazonaws.com \
--master --users 100000 --spawn-rate 500
# Worker (run in separate terminals)
locust -f traffic_simulator.py --worker --master-host localhost| Metric | Value |
|---|---|
| Total requests | 107,811 |
| Failures | 149 (0.14%) — all during initial scale-up |
| Median response time | 550ms |
| 95th percentile | 800ms |
| Peak req/s | ~997 |
| Final task count | 10 (auto-scaled from 2) |
# Check service status
aws ecs describe-services \
--cluster clickstream-cluster \
--services clickstream-fastapi-service \
--region us-east-1 \
--query "services[0].{running:runningCount,desired:desiredCount}"
# Force redeploy
aws ecs update-service \
--cluster clickstream-cluster \
--service clickstream-fastapi-service \
--force-new-deployment \
--region us-east-1
# Watch tasks live
watch -n 10 "aws ecs describe-services \
--cluster clickstream-cluster \
--services clickstream-fastapi-service \
--region us-east-1 \
--query 'services[0].{running:runningCount,desired:desiredCount}'"# List clusters
aws kafka list-clusters --region us-east-1
# Get broker endpoints
aws kafka get-bootstrap-brokers \
--cluster-arn arn:aws:kafka:us-east-1:155409188308:cluster/clickstream-kafka/3d146113-b677-4d17-ab59-c6a2e6b75fd1-6 \
--region us-east-1# List job runs
aws emr-serverless list-job-runs \
--application-id 00g687l6tffeq309 \
--region us-east-1 \
--query "jobRuns[*].{id:id,state:state}"
# Cancel job
aws emr-serverless cancel-job-run \
--application-id 00g687l6tffeq309 \
--job-run-id <JOB_ID> \
--region us-east-1
# Stop application
aws emr-serverless stop-application \
--application-id 00g687l6tffeq309 \
--region us-east-1# Check parquet files
aws s3 ls s3://clickstream-pipeline/parquet/ --recursive | wc -l
# Check jars
aws s3 ls s3://clickstream-pipeline/jars/
# Upload updated script
aws s3 cp spark/spark_streaming.py s3://clickstream-pipeline/scripts/spark_streaming.py# Document count
curl -u "admin:<password>" \
"https://search-clickstream-j3glehr2glclhpparfwtmi5mte.us-east-1.es.amazonaws.com/clickstream/_count"
# Delete index (reset)
curl -X DELETE -u "admin:<password>" \
"https://search-clickstream-j3glehr2glclhpparfwtmi5mte.us-east-1.es.amazonaws.com/clickstream"# Cancel EMR job
JOB_ID=$(aws emr-serverless list-job-runs --application-id 00g687l6tffeq309 --region us-east-1 --query "jobRuns[?state=='RUNNING'].id" --output text)
aws emr-serverless cancel-job-run --application-id 00g687l6tffeq309 --job-run-id $JOB_ID --region us-east-1
# Stop EMR application
aws emr-serverless stop-application --application-id 00g687l6tffeq309 --region us-east-1
# Scale ECS to 0
aws ecs update-service --cluster clickstream-cluster --service clickstream-fastapi-service --desired-count 0 --region us-east-1
# Delete OpenSearch (can't pause)
aws opensearch delete-domain --domain-name clickstream --region us-east-1
# Delete MSK
aws kafka delete-cluster --cluster-arn <CLUSTER_ARN> --region us-east-1
# Delete Redshift
aws redshift-serverless delete-workgroup --workgroup-name clickstream-workgroup --region ap-southeast-2
aws redshift-serverless delete-namespace --namespace-name clickstream-namespace --region ap-southeast-2All resources are on minimal paid tier for demo purposes:
- ECS Fargate: ~$0.04/vCPU-hour. 2 tasks idle ≈ $1.50/day.
- MSK:
kafka.t3.small× 3 brokers ≈ $1.20/day. - EMR Serverless: Charged only when job runs. ~$0.052/vCPU-hour.
- OpenSearch:
t3.small.search× 1 node ≈ $0.86/day. - Redshift Serverless: Charged per RPU-hour of queries. Minimal for demo.
- S3: < $0.01/day for demo data volumes.
This project demonstrates end-to-end production data engineering:
- Real-time ingestion at 10K–100K users with auto-scaling
- Stream processing with Spark on managed EMR — two concurrent output streams
- Operational analytics via OpenSearch + Kibana with 5-second refresh
- Batch ETL via Airflow — sessionization, engagement scoring, Redshift load
GitHub: shan1322/clickstream-data-pipeline
YouTube Demo: Watch → @shantanuds06





