Optimize your MediaLive ABR ladder: Identify unused renditions and improve viewer experience using CloudFront access logs
Most media customers deploy MediaLive with default ABR rendition ladders without visibility into which renditions viewers actually consume. This article provides a complete, deployable solution to analyze CloudFront access logs via Athena and correlate them with MediaLive HLS output configurations. Customers can identify underused renditions to eliminate (reducing encoding costs by 20-40%) and discover where viewer demand is concentrated to invest in higher-quality renditions that improve viewin
Optimize your MediaLive ABR ladder: Identify unused renditions and improve viewer experience using CloudFront access logs
Introduction
When deploying adaptive bitrate (ABR) streaming workflows with AWS Elemental MediaLive, many customers use default rendition ladders without visibility into which renditions their viewers actually consume. A typical HLS output group might include 6 or more renditions (1080p, 720p, 480p, 360p, 240p) — but if only 5% of viewers ever request the lowest or highest renditions, you're paying for encoding and delivery that provides no viewer benefit.
Beyond cost savings — improving viewer experience:
If 93% of your viewers are consuming the highest-quality rendition (e.g., 720p60), that's a signal to invest in that experience — not just optimize costs. Consider:
- Adding a higher rendition (e.g., 1080p) since your viewers clearly have the bandwidth for it
- Adding intermediate steps in the bitrate ladder (e.g., 720p at 4Mbps between your 5.6Mbps and 3.4Mbps) to reduce ABR switching artifacts
- Allocating the encoding budget saved from eliminating unused renditions toward better quality in the renditions viewers actually watch
This analysis helps you make data-driven decisions that optimize both cost AND viewer experience.
This article shows you how to correlate Amazon CloudFront access logs with your MediaLive HLS output configuration to:
- Identify which renditions are most and least requested by viewers
- Quantify bandwidth consumption and cost per rendition
- Generate data-driven recommendations to eliminate unused renditions or optimize bitrates
- Potentially reduce encoding and delivery costs by 20–40%
- Improve viewer experience by investing in the renditions your audience actually consumes — adding higher-quality variants where demand is concentrated
Architecture Overview
This solution supports two common delivery architectures:
Architecture A: MediaLive → S3 → CloudFront
┌──────────┐ ┌────────┐ ┌─────────────┐ ┌───────────┐
│MediaLive │───▶│ S3 │───▶│ CloudFront │───▶│ Viewers │
│ │ │(Origin)│ │(Distribution)│ │ │
└──────────┘ └────────┘ └──────┬──────┘ └───────────┘
│
Architecture B: MediaLive → MediaPackage → CloudFront (Recommended)
┌──────────┐ ┌──────────────┐ ┌─────────────┐ ┌───────────┐
│MediaLive │───▶│ MediaPackage │───▶│ CloudFront │───▶│ Viewers │
│ │ │ (Origin) │ │(Distribution)│ │ │
└──────────┘ └──────────────┘ └──────┬──────┘ └───────────┘
│
Both architectures: ▼ Access Logs
┌─────────┐
│ S3 │
│ (Logs) │
└────┬────┘
│
▼
┌─────────┐ ┌───────────────┐
│ Athena │────▶│ QuickSight / │
│ │ │ Report │
└─────────┘ └───────────────┘
Note: This solution works with both architectures. The CloudFront access logs capture viewer request patterns regardless of whether the origin is S3 or MediaPackage. The only difference is where you fetch the parent manifest from (S3 bucket vs. MediaPackage/CloudFront endpoint).
Prerequisites
- An AWS account with AWS Elemental MediaLive producing HLS output
- An Amazon CloudFront distribution serving the HLS content to viewers
- Permissions to enable CloudFront standard logging
- Amazon Athena configured in the same region as your log bucket
Deployment: CloudFormation Template
This article includes at the bottom of this page a companion CloudFormation template (rendition-advisor-pipeline.yaml) that deploys the entire analysis pipeline in a single command — without modifying your existing MediaLive or CloudFront setup.
The template adds only the monitoring layer:
- S3 bucket for logs (or uses your existing log bucket — configurable)
- Glue/Athena tables with the correct schema (including real-time logs v2 support)
- Pre-built Athena named queries (ready to run from the console)
- Optional: Lambda + EventBridge for weekly automated reports with SNS notifications
Deploy command:
# If you ALREADY have CloudFront logging to an existing bucket: aws cloudformation deploy \ --template-file rendition-advisor-pipeline.yaml \ --stack-name rendition-advisor \ --parameter-overrides \ CloudFrontDistributionId=E1YOURDISTR1D \ ChannelName=my-channel \ ExistingLogBucket=my-existing-cf-log-bucket \ ExistingLogPrefix="cf-logs/" \ LogType=standard \ --capabilities CAPABILITY_NAMED_IAM # If you need a NEW log bucket (fresh setup): aws cloudformation deploy \ --template-file rendition-advisor-pipeline.yaml \ --stack-name rendition-advisor \ --parameter-overrides \ CloudFrontDistributionId=E1YOURDISTR1D \ ChannelName=my-channel \ --capabilities CAPABILITY_NAMED_IAM
Tear-down:
aws cloudformation delete-stack --stack-name rendition-advisorremoves everything cleanly.
Step 1: Enable CloudFront Standard Logging
Enable access logging on your CloudFront distribution to capture every viewer request, including which HLS segment URIs they access.
Via AWS Console:
- Open the CloudFront console → select your distribution
- Go to General → Settings → Edit
- Under Standard Logging, set to On
- Choose an S3 bucket for logs (e.g.,
my-cf-access-logs) - Optionally set a log prefix (e.g.,
hls-channel-01/) - Save changes
Via AWS CLI:
aws cloudfront update-distribution --id E1EXAMPLE \ --distribution-config '{ "Logging": { "Enabled": true, "IncludeCookies": false, "Bucket": "my-cf-access-logs.s3.amazonaws.com", "Prefix": "hls-channel-01/" } }'
Note: Logs typically begin appearing within 15–30 minutes of enabling. Each log file covers roughly 1 hour of traffic.
Step 2: Verify Your MediaLive Output Naming Convention
The analysis relies on being able to extract the rendition identifier from the request URI. Your HLS output paths should include a distinguishable rendition segment.
Important: If your output paths use generic names (e.g.,
output_1.ts,output_2.ts) instead of resolution-based names, skip ahead to Step 2B: Auto-Discover Renditions from the HLS parent Manifest below.
Common naming patterns:
| Pattern | Example URI | Rendition Extracted |
|---|---|---|
| Resolution-based | /live/channel/1080p/segment_00001.ts | 1080p |
| Bitrate-based | /live/channel/6000k/segment_00001.ts | 6000k |
| Index-based | /live/channel/rendition_0/segment_00001.ts | rendition_0 |
MediaLive HLS Output Group configuration example:
{ "OutputGroupSettings": { "HlsGroupSettings": { "Destination": { "DestinationRefId": "s3-output" }, "SegmentLength": 6, "IndexNSegments": 3 } }, "Outputs": [ { "OutputName": "1080p", "VideoDescriptionName": "video_1080p", "OutputSettings": { "HlsOutputSettings": { "NameModifier": "_1080p" } } }, { "OutputName": "720p", "VideoDescriptionName": "video_720p", "OutputSettings": { "HlsOutputSettings": { "NameModifier": "_720p" } } }, { "OutputName": "480p", "VideoDescriptionName": "video_480p", "OutputSettings": { "HlsOutputSettings": { "NameModifier": "_480p" } } } ] }
Tip: If your output paths don't include a recognizable rendition identifier, update your MediaLive Output Group's
NameModifieror destination path to include one (e.g.,_1080p,_720p). This requires a channel stop/start but makes analysis significantly easier.
Step 2B: Auto-Discover Renditions from the HLS parent Manifest
If your channel uses generic output names (e.g., index_1, index_2, output_1) that don't indicate the resolution or bitrate, you can automatically build a rendition lookup table by parsing the HLS parent manifest (.m3u8).
MediaPackage users: When MediaPackage is your CloudFront origin, the manifest is served via the MediaPackage endpoint (not directly from S3). Fetch it from your CloudFront URL or MediaPackage origin endpoint — see the script options below.
The parent playlist contains #EXT-X-STREAM-INF tags that map each variant stream URI to its actual resolution and bandwidth:
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=6000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
output_1/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1280x720,CODECS="avc1.4d401f,mp4a.40.2"
output_2/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1500000,RESOLUTION=854x480,CODECS="avc1.4d401e,mp4a.40.2"
output_3/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360,CODECS="avc1.42c01e,mp4a.40.2"
output_4/index.m3u8
curl -vvv https://cloudfront.net/index.m3u8
Python script to parse the manifest and build the rendition map
This script supports two manifest sources:
- S3 bucket (direct S3 origin — MediaLive output to S3)
- HTTP URL (MediaPackage origin — manifest served via CloudFront or MediaPackage endpoint)
Save this as parse_manifest.py and run with python3 parse_manifest.py:
import boto3 import re import json try: import requests HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False import urllib.request def fetch_manifest_from_s3(bucket, key): """Fetch manifest from S3 bucket (direct S3 origin).""" s3 = boto3.client('s3') response = s3.get_object(Bucket=bucket, Key=key) return response['Body'].read().decode('utf-8') def fetch_manifest_from_url(url): """Fetch manifest from HTTP URL (MediaPackage/CloudFront endpoint).""" if HAS_REQUESTS: response = requests.get(url) response.raise_for_status() return response.text else: with urllib.request.urlopen(url) as response: return response.read().decode('utf-8') def parse_manifest(manifest_content): """ Parse an HLS parent manifest to extract rendition information. Works with any HLS parent playlist regardless of origin (S3, MediaPackage, etc.) """ rendition_map = [] lines = manifest_content.strip().split('\n') i = 0 while i < len(lines): line = lines[i].strip() if line.startswith('#EXT-X-STREAM-INF:'): attrs = {} bw_match = re.search(r'BANDWIDTH=(\d+)', line) avg_bw_match = re.search(r'AVERAGE-BANDWIDTH=(\d+)', line) res_match = re.search(r'RESOLUTION=(\d+x\d+)', line) fps_match = re.search(r'FRAME-RATE=([\d.]+)', line) codecs_match = re.search(r'CODECS="([^"]+)"', line) if bw_match: attrs['bandwidth'] = int(bw_match.group(1)) if avg_bw_match: attrs['avg_bandwidth'] = int(avg_bw_match.group(1)) if res_match: attrs['resolution'] = res_match.group(1) if fps_match: attrs['framerate'] = fps_match.group(1) if codecs_match: attrs['codecs'] = codecs_match.group(1) # Next non-comment line is the URI i += 1 while i < len(lines) and lines[i].strip().startswith('#'): i += 1 if i < len(lines): uri = lines[i].strip() # Extract prefix: "index_1.m3u8" -> "index_1", "path/to/720p.m3u8" -> "720p" prefix = uri.split('/')[-1].replace('.m3u8', '') if '/' in uri else uri.replace('.m3u8', '') # Build human-readable label height = attrs.get('resolution', '0x0').split('x')[1] fps = attrs.get('framerate', '29.97') fps_label = '60' if float(fps) > 50 else '30' avg_bw = attrs.get('avg_bandwidth', attrs.get('bandwidth', 0)) bw_mbps = avg_bw / 1_000_000 label = f"{height}p{fps_label}_{bw_mbps:.1f}M" rendition_map.append({ 'uri_prefix': prefix, 'resolution': attrs.get('resolution', 'unknown'), 'bandwidth': avg_bw, 'label': label, 'framerate': attrs.get('framerate', 'unknown'), }) i += 1 return rendition_map # ============================== # INTERACTIVE PROMPTS # ============================== if __name__ == '__main__': print("=" * 60) print(" HLS Rendition Manifest Parser") print(" Supports: S3 direct origin OR MediaPackage/HTTP endpoint") print("=" * 60) print("\nWhere is your parent manifest?") print(" 1) S3 bucket (MediaLive → S3 origin)") print(" 2) HTTP URL (MediaPackage → CloudFront endpoint)") choice = input("\nEnter 1 or 2: ").strip() if choice == '1': bucket = input("S3 bucket name: ").strip() key = input("S3 key (e.g., live/channel1/index.m3u8): ").strip() print(f"\nFetching s3://{bucket}/{key} ...") manifest_content = fetch_manifest_from_s3(bucket, key) elif choice == '2': url = input("Manifest URL (e.g., https://d1234abc.cloudfront.net/out/v1/abc123/index.m3u8): ").strip() print(f"\nFetching {url} ...") manifest_content = fetch_manifest_from_url(url) else: print("Invalid choice. Exiting.") exit(1) # Parse rendition_map = parse_manifest(manifest_content) # Display results print(f"\n{'='*60}") print(f" Found {len(rendition_map)} renditions:") print(f"{'='*60}") for r in rendition_map: print(f" {r['uri_prefix']:>12} → {r['resolution']} @ {r['bandwidth']/1e6:.1f} Mbps ({r['label']})") # Upload lookup_bucket = input("\nRendition lookup S3 bucket (from CFN stack output): ").strip() output_file = '/tmp/rendition_map.json' with open(output_file, 'w') as f: for r in rendition_map: f.write(json.dumps(r) + '\n') s3 = boto3.client('s3') s3.upload_file(output_file, lookup_bucket, 'lookup/rendition_map.json') print(f"\n✅ Uploaded to s3://{lookup_bucket}/lookup/rendition_map.json") print(" Athena can now JOIN with human-readable rendition labels.")
Run it:
python3 parse_manifest.py
It will ask:
- Source type (S3 or HTTP URL)
- Bucket/key or URL
- Lookup bucket to upload the result
Tip: Run this script once per channel. Only re-run if you change your encoding ladder (add/remove renditions). For MediaPackage users, your parent manifest URL is typically:
https://<your-cf-domain>/out/v1/<mediapackage-endpoint-id>/index.m3u8
Step 3: Create an Athena Table for CloudFront Logs
Create an external table in Athena that maps to your CloudFront log format:
CREATE EXTERNAL TABLE IF NOT EXISTS cloudfront_hls_logs ( `date` DATE, `time` STRING, x_edge_location STRING, sc_bytes BIGINT, c_ip STRING, cs_method STRING, cs_host STRING, cs_uri_stem STRING, sc_status INT, cs_referer STRING, cs_user_agent STRING, cs_uri_query STRING, cs_cookie STRING, x_edge_result_type STRING, x_edge_request_id STRING, x_host_header STRING, cs_protocol STRING, cs_bytes BIGINT, time_taken FLOAT, x_forwarded_for STRING, ssl_protocol STRING, ssl_cipher STRING, x_edge_response_result_type STRING, cs_protocol_version STRING, fle_status STRING, fle_encrypted_fields INT, c_port INT, time_to_first_byte FLOAT, x_edge_detailed_result_type STRING, sc_content_type STRING, sc_content_len BIGINT, sc_range_start BIGINT, sc_range_end BIGINT ) ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' LOCATION 's3://my-cf-access-logs/hls-channel-01/' TBLPROPERTIES ('skip.header.line.count'='2');
Step 4: Query Rendition Usage Patterns
Basic rendition request distribution
-- Extract rendition from URI path and count requests SELECT REGEXP_EXTRACT(cs_uri_stem, '/([0-9]+p[^/]*|[0-9]+k[^/]*)/', 1) AS rendition, COUNT(*) AS segment_requests, SUM(sc_bytes) / POWER(1024, 3) AS total_gb, COUNT(DISTINCT c_ip) AS unique_viewers, ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) AS request_pct FROM cloudfront_hls_logs WHERE cs_uri_stem LIKE '%.ts' AND sc_status = 200 AND "date" >= DATE '2026-07-27' GROUP BY REGEXP_EXTRACT(cs_uri_stem, '/([0-9]+p[^/]*|[0-9]+k[^/]*)/', 1) ORDER BY segment_requests DESC;
Note: Adjust the
REGEXP_EXTRACTpattern to match your specific URI structure. The pattern above captures resolution-based identifiers like1080p,720p, or bitrate-based ones like6000k.
Rendition usage by time of day (peak vs. off-peak)
SELECT REGEXP_EXTRACT(cs_uri_stem, '/([0-9]+p[^/]*)/', 1) AS rendition, HOUR(CAST("time" AS TIME)) AS hour_of_day, COUNT(*) AS requests FROM cloudfront_hls_logs WHERE cs_uri_stem LIKE '%.ts' AND sc_status = 200 GROUP BY 1, 2 ORDER BY rendition, hour_of_day;
Geographic distribution of renditions
SELECT REGEXP_EXTRACT(cs_uri_stem, '/([0-9]+p[^/]*)/', 1) AS rendition, x_edge_location, COUNT(*) AS requests, SUM(sc_bytes) / POWER(1024, 3) AS total_gb FROM cloudfront_hls_logs WHERE cs_uri_stem LIKE '%.ts' AND sc_status = 200 GROUP BY 1, 2 ORDER BY rendition, requests DESC;
Step 5: Correlate with MediaLive Configuration
This step retrieves the actual encoding configuration from your MediaLive channels and , then builds a unified rendition map that matches output paths to resolution/bitrate specs.
5.1 Discover Renditions from MediaLive Channels
Save this as discover_renditions.py and run with python3 discover_renditions.py. The script will list your channels and ask you to select one:
import boto3 import json def list_medialive_channels(region): """List all MediaLive channels in the account.""" ml = boto3.client('medialive', region_name=region) channels = [] paginator = ml.get_paginator('list_channels') for page in paginator.paginate(): for ch in page['Channels']: channels.append({ 'id': ch['Id'], 'name': ch['Name'], 'state': ch['State'], }) return channels def get_medialive_renditions(channel_id, region): """ Retrieve rendition configuration from a MediaLive channel. Maps each HLS output's NameModifier to its video encoding settings. """ ml = boto3.client('medialive', region_name=region) channel = ml.describe_channel(ChannelId=channel_id) # Build video description lookup video_descriptions = {} for vd in channel['EncoderSettings']['VideoDescriptions']: video_descriptions[vd['Name']] = { 'width': vd.get('Width'), 'height': vd.get('Height'), 'bitrate': vd.get('CodecSettings', {}).get('H264Settings', {}).get('Bitrate', 0), 'profile': vd.get('CodecSettings', {}).get('H264Settings', {}).get('Profile', 'MAIN'), 'framerate_num': vd.get('CodecSettings', {}).get('H264Settings', {}).get('FramerateNumerator'), 'framerate_den': vd.get('CodecSettings', {}).get('H264Settings', {}).get('FramerateDenominator'), } renditions = [] for og in channel['EncoderSettings']['OutputGroups']: hls_settings = og.get('OutputGroupSettings', {}).get('HlsGroupSettings') if not hls_settings: continue for output in og['Outputs']: hls_output = output['OutputSettings'].get('HlsOutputSettings', {}) name_modifier = hls_output.get('NameModifier', '') video_desc_name = output.get('VideoDescriptionName', '') video_info = video_descriptions.get(video_desc_name, {}) height = video_info.get('height', '?') bitrate = video_info.get('bitrate', 0) renditions.append({ 'uri_prefix': name_modifier.strip('_'), 'output_name': output.get('OutputName', ''), 'width': video_info.get('width'), 'height': height, 'bitrate': bitrate, 'profile': video_info.get('profile'), 'label': f"{height}p_{bitrate/1e6:.1f}M" if bitrate else f"{height}p", }) return { 'channel_id': channel_id, 'channel_name': channel['Name'], 'state': channel['State'], 'renditions': renditions, } # ============================== # INTERACTIVE PROMPTS # ============================== if __name__ == '__main__': print("=" * 60) print(" MediaLive Rendition Discovery") print("=" * 60) region = input("\nAWS Region [us-east-1]: ").strip() or 'us-east-1' # List all channels print(f"\nFetching MediaLive channels in {region}...") channels = list_medialive_channels(region) if not channels: print(" No MediaLive channels found in this region.") exit(1) print(f"\n Found {len(channels)} channel(s):") print(f" {'#':<4} {'Channel ID':<12} {'Name':<30} {'State':<10}") print(f" {'-'*60}") for i, ch in enumerate(channels, 1): print(f" {i:<4} {ch['id']:<12} {ch['name']:<30} {ch['state']:<10}") # Ask user to select print() selection = input("Enter channel number (or channel ID directly): ").strip() if selection.isdigit() and int(selection) <= len(channels): channel_id = channels[int(selection) - 1]['id'] else: channel_id = selection # Get renditions print(f"\nFetching rendition config for channel {channel_id}...") info = get_medialive_renditions(channel_id, region) print(f"\n{'='*60}") print(f" Channel: {info['channel_name']} (ID: {info['channel_id']})") print(f" State: {info['state']}") print(f" Renditions: {len(info['renditions'])}") print(f"{'='*60}") for r in info['renditions']: print(f" {r['name_modifier']:>12} → {r['width']}x{r['height']} " f"@ {r['bitrate']/1e6:.1f}Mbps ({r['label']})") # Ask to save as lookup print() save = input("Upload as rendition lookup to S3? (y/n): ").strip().lower() if save == 'y': lookup_bucket = input("Rendition lookup S3 bucket: ").strip() lookup_rows = [] for r in info['renditions']: lookup_rows.append({ 'uri_prefix': r['name_modifier'].strip('_'), 'resolution': f"{r['width']}x{r['height']}", 'bandwidth': r['bitrate'], 'label': r['label'], }) output_file = '/tmp/rendition_map.json' with open(output_file, 'w') as f: for row in lookup_rows: f.write(json.dumps(row) + '\n') s3 = boto3.client('s3') s3.upload_file(output_file, lookup_bucket, 'lookup/rendition_map.json') print(f"\n✅ Uploaded to s3://{lookup_bucket}/lookup/rendition_map.json") print("\nDone!")
Run it:
python3 discover_renditions.py
The script will:
- Ask for your AWS region
- List all MediaLive channels in your account
- Ask you to select a channel (by number or ID)
- Display the rendition ladder for that channel
- Optionally upload the lookup to S3 for Athena
5.3 Build Unified Rendition Map (All Sources)
This script scans all active channels, builds a combined lookup, and uploads it to S3 for Athena to join against:
def build_unified_rendition_map(region='us-east-1'): """ Scan all MediaLive channels in the account. Build a unified rendition lookup table for Athena. Returns a list of dicts suitable for uploading as JSON lines to S3. """ ml = boto3.client('medialive', region_name=region) lookup_rows = [] # --- MediaLive channels --- paginator = ml.get_paginator('list_channels') for page in paginator.paginate(): for ch in page['Channels']: try: info = get_medialive_renditions(ch['Id'], region) for r in info['renditions']: lookup_rows.append({ 'source_type': 'MediaLive', 'source_id': ch['Id'], 'source_name': info['channel_name'], 'uri_prefix': r['name_modifier'].strip('_'), 'width': r['width'], 'height': r['height'], 'bitrate': r['bitrate'], 'label': r['label'], }) except Exception as e: print(f" Warning: Could not read channel {ch['Id']}: {e}") return lookup_rows def upload_rendition_lookup(lookup_rows, bucket, prefix='lookup/'): """Upload the rendition map as JSON lines to S3 for Athena.""" s3 = boto3.client('s3') content = '\n'.join(json.dumps(row) for row in lookup_rows) s3.put_object( Bucket=bucket, Key=f"{prefix}rendition_map.json", Body=content.encode('utf-8'), ContentType='application/json' ) print(f"Uploaded {len(lookup_rows)} rendition entries to s3://{bucket}/{prefix}rendition_map.json") # === Usage === lookup = build_unified_rendition_map(region='us-east-1') # Upload to the bucket created by the CloudFormation template upload_rendition_lookup(lookup, bucket='rendition-advisor-rendition-lookup-123456789012')
5.4 Athena: Per-Channel and Per-Template Usage Breakdown
Once the unified lookup is in Athena, query per-source usage:
-- Distinguish MediaLive (live) vs traffic -- and break down rendition usage PER channel SELECT rl.label AS rendition, ROUND(rl.bitrate / 1000000.0, 1) AS bitrate_mbps, COUNT(*) AS segment_requests, ROUND(SUM(cf.sc_bytes) / POWER(1024, 3), 2) AS total_gb, COUNT(DISTINCT cf.c_ip) AS unique_viewers, ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER( PARTITION BY rl.label ), 2) AS pct_within_channel FROM cloudfront_hls_logs cf JOIN rendition_lookup rl ON REGEXP_EXTRACT(cf.cs_uri_stem, '(index_[0-9]+)', 1) = rl.uri_prefix WHERE (cf.cs_uri_stem LIKE '%.ts' OR cf.cs_uri_stem LIKE '%.m4s') AND cf.sc_status = 200 AND cf."date" >= CURRENT_DATE - INTERVAL '7' DAY GROUP BY rl.label, rl.resolution, rl.bandwidth ORDER BY segment_requests DESC;
Key insight: The
pct_within_channelcolumn tells you the rendition distribution within each specific channel — so you can make per-workflow optimization decisions rather than averaging across all content.
5.5 Per-Channel Cost Attribution
-- Weekly cost breakdown per channel per rendition WITH rendition_usage AS ( SELECT rl.label AS rendition, rl.bandwidth AS bitrate, COUNT(*) AS requests, SUM(cf.sc_bytes) / POWER(1024, 3) AS gb_delivered FROM cloudfront_hls_logs cf JOIN rendition_lookup rl ON REGEXP_EXTRACT(cf.cs_uri_stem, '(index_[0-9]+)', 1) = rl.uri_prefix WHERE (cf.cs_uri_stem LIKE '%.ts' OR cf.cs_uri_stem LIKE '%.m4s') AND cf.sc_status = 200 AND cf."date" >= CURRENT_DATE - INTERVAL '7' DAY GROUP BY 1, 2 ) SELECT rendition, requests, ROUND(gb_delivered, 4) AS gb_delivered, ROUND(gb_delivered * 0.085, 4) AS cf_cost_usd, CASE WHEN bitrate >= 5000000 THEN ROUND(1.08 * 168, 2) WHEN bitrate >= 2500000 THEN ROUND(0.54 * 168, 2) WHEN bitrate >= 1000000 THEN ROUND(0.27 * 168, 2) WHEN bitrate >= 500000 THEN ROUND(0.14 * 168, 2) ELSE ROUND(0.07 * 168, 2) END AS encoding_cost_weekly, ROUND(requests * 100.0 / SUM(requests) OVER(), 2) AS usage_pct FROM rendition_usage ORDER BY requests DESC;
Step 6: Cost Estimation per Rendition
Combine the usage data with AWS pricing to estimate per-rendition costs:
import boto3 import pandas as pd # --- Configuration --- ENCODING_COST_PER_HOUR = { # MediaLive AVC encoding, single pipeline, approximate USD/hour "1080p": 1.08, "720p": 0.54, "480p": 0.27, "360p": 0.14, "240p": 0.07, } CF_PRICE_PER_GB = 0.085 # CloudFront first 10TB tier (US/Europe) def estimate_rendition_costs(athena_results_df, hours_running=168): """ Estimate weekly cost per rendition (encoding + delivery). Args: athena_results_df: DataFrame with columns [rendition, total_gb] hours_running: Hours the channel was live (168 = 7 days 24/7) """ df = athena_results_df.copy() # CloudFront delivery cost df['cf_cost'] = df['total_gb'] * CF_PRICE_PER_GB # MediaLive encoding cost (fixed regardless of viewers) df['ml_cost'] = df['rendition'].map(ENCODING_COST_PER_HOUR) * hours_running # Total df['total_cost'] = df['cf_cost'] + df['ml_cost'] df['cost_per_viewer'] = df['total_cost'] / df['unique_viewers'] return df.sort_values('total_cost', ascending=False) def generate_recommendations(cost_df, min_request_pct=3.0): """ Generate optimization recommendations based on usage and cost. Rules: - ELIMINATE: < min_request_pct of total requests - OPTIMIZE: Bandwidth % more than 2x request % (bitrate too high) - KEEP: Healthy usage-to-cost ratio """ recommendations = [] for _, row in cost_df.iterrows(): if row['request_pct'] < min_request_pct: recommendations.append({ 'rendition': row['rendition'], 'action': 'ELIMINATE', 'reason': f"Only {row['request_pct']:.1f}% of requests. " f"Removing saves ${row['total_cost']:.2f}/week " f"(${row['total_cost']*52:.0f}/year).", }) elif row.get('bandwidth_pct', 0) > row['request_pct'] * 2: recommendations.append({ 'rendition': row['rendition'], 'action': 'OPTIMIZE', 'reason': f"Bandwidth ({row['bandwidth_pct']:.1f}%) is " f"disproportionate to requests ({row['request_pct']:.1f}%). " f"Consider reducing bitrate.", }) else: recommendations.append({ 'rendition': row['rendition'], 'action': 'KEEP', 'reason': f"Healthy adoption ({row['request_pct']:.1f}%) " f"with proportional cost.", }) return recommendations
Step 7: Automate with a Weekly Report (Optional)
Set up a scheduled analysis using AWS Lambda + EventBridge:
import boto3 import json from datetime import datetime, timedelta def lambda_handler(event, context): """ Weekly rendition usage analysis. Triggered by EventBridge rule: rate(7 days) """ athena = boto3.client('athena') sns = boto3.client('sns') # Run Athena query for the past 7 days end_date = datetime.now().strftime('%Y-%m-%d') start_date = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d') query = f""" SELECT REGEXP_EXTRACT(cs_uri_stem, '/([0-9]+p[^/]*)/', 1) AS rendition, COUNT(*) AS segment_requests, SUM(sc_bytes) / POWER(1024, 3) AS total_gb, COUNT(DISTINCT c_ip) AS unique_viewers, ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) AS request_pct FROM cloudfront_hls_logs WHERE cs_uri_stem LIKE '%.ts' AND sc_status = 200 AND "date" BETWEEN DATE '{start_date}' AND DATE '{end_date}' GROUP BY 1 ORDER BY segment_requests DESC """ response = athena.start_query_execution( QueryString=query, QueryExecutionContext={'Database': 'default'}, ResultConfiguration={ 'OutputLocation': 's3://my-athena-results/rendition-analysis/' } ) # Process results and send SNS notification with recommendations # (Add polling for query completion, result parsing, and SNS publish) return { 'statusCode': 200, 'queryExecutionId': response['QueryExecutionId'] }
EventBridge rule (Terraform/CloudFormation snippet):
RenditionAnalysisRule: Type: AWS::Events::Rule Properties: ScheduleExpression: "rate(7 days)" Targets: - Arn: !GetAtt RenditionAnalysisFunction.Arn Id: WeeklyRenditionAnalysis
Step 8: Visualize with Amazon QuickSight
For ongoing monitoring, create a QuickSight dashboard connected to your Athena table. This section provides the full reproducible steps to set up the dashboard on your own account.
8.1 Prerequisites for QuickSight
- Sign up for QuickSight (if not already): Go to the QuickSight Console → choose Enterprise Edition (required for Athena data source)
- Grant QuickSight access to your S3 log bucket: QuickSight Console → Manage QuickSight → Security & Permissions → Add S3 bucket access → select your
my-cf-access-logsbucket and your Athena results bucket - Grant QuickSight access to Athena: In the same Security & Permissions page, ensure Amazon Athena is checked
8.2 Create the Athena Dataset
- In QuickSight, click Datasets → New Dataset
- Choose Athena as the data source
- Data source name:
cloudfront-hls-rendition-logs - Athena workgroup:
primary(or your configured workgroup) - Click Validate connection → Create data source
- Select your database (e.g.,
default) → choose thecloudfront_hls_logstable - Select Import to SPICE for quicker analytics (recommended for large log volumes)
- Click Edit/Preview Data before finishing
8.3 Add Calculated Fields
In the dataset editor, add these calculated fields:
Rendition (extracted from URI) — if using named paths:
ifelse(
locate({cs_uri_stem}, '1080p') > 0, '1080p',
locate({cs_uri_stem}, '720p') > 0, '720p',
locate({cs_uri_stem}, '480p') > 0, '480p',
locate({cs_uri_stem}, '360p') > 0, '360p',
locate({cs_uri_stem}, '240p') > 0, '240p',
'unknown'
)
Rendition (extracted from URI) — if using generic output names (from manifest parsing):
# Extract output prefix from URI like /live/channel1/output_1/segment_00001.ts
# Then join with the rendition_lookup dataset (see Step 2B)
split({cs_uri_stem}, '/', 4)
After creating this field, add a lookup join: Datasets → your dataset → click "Add data" → join with a
rendition_lookupCSV you upload (output from the manifest parser). Join onuri_prefix= this calculated field.
Bytes in GB:
{sc_bytes} / 1073741824
Is Successful Request:
ifelse({sc_status} = 200, 'Yes', 'No')
Is Segment Request (.ts):
ifelse(locate({cs_uri_stem}, '.ts') > 0, 'Yes', 'No')
Hour of Day:
substring({time}, 1, 2)
Click Save & Publish → confirm SPICE import.
8.4 Create the Analysis (Dashboard)
- Go to Analyses → New Analysis → select your
cloudfront-hls-rendition-logsdataset - Build the following visuals on a single sheet:
Visual 1: Rendition Request Distribution (Donut Chart)
- Visual type: Donut chart
- Group/Color:
rendition(calculated field) - Value:
Countof records - Filter:
Is Segment Request=YesANDIs Successful Request=Yes - Title: "Request Distribution by Rendition"
Visual 2: Bandwidth per Rendition (Horizontal Bar)
- Visual type: Horizontal bar chart
- Y-axis:
rendition - Value:
SumofBytes in GB - Filter: same as Visual 1
- Sort: by value descending
- Title: "Bandwidth Consumption (GB) per Rendition"
Visual 3: Daily Trend by Rendition (Line Chart)
- Visual type: Line chart
- X-axis:
date(aggregate by Day) - Value:
Countof records - Color/Group:
rendition - Filter: same as Visual 1
- Title: "Daily Request Volume by Rendition"
Visual 4: Geographic Distribution (Heat Map)
- Visual type: Heat map
- Rows:
rendition - Columns:
x_edge_location - Values:
Countof records - Filter: same as Visual 1
- Title: "Rendition Popularity by Edge Location"
Visual 5: Hourly Usage Pattern (Stacked Area)
- Visual type: Stacked area chart
- X-axis:
Hour of Day(sorted ascending 00–23) - Value:
Countof records - Color/Group:
rendition - Filter: same as Visual 1
- Title: "Rendition Requests by Hour of Day"
Visual 6: KPI Cards (Top row of the dashboard)
Add KPI visuals for:
- Total Requests:
Countof records, filtered to.ts+ status 200 - Total Data Transferred:
SumofBytes in GB - Unique Viewers:
Count Distinctofc_ip
8.4b Per-Channel Breakdown Visuals
If you serve content from multiple MediaLive channels through the same CloudFront distribution, add these additional visuals to see per-source rendition usage:
Visual 7: Per-Channel Rendition Usage (Grouped Bar Chart)
- Visual type: Clustered bar chart
- X-axis:
channel_name(from the joined rendition_lookup) - Value:
Countof records - Group/Color:
rendition - Filter: same base filters (
.ts+ status 200) - Title: "Rendition Distribution per Channel"
Visual 8: Source Service Split (Pie Chart)
- Visual type: Pie chart
- Group:
source_service(based on channel identifier in the URI path) - Value:
Countof records - Title: "Traffic by Edge Location"
Calculated field for source_service:
ifelse(
locate({cs_uri_stem}, '/live/') > 0, 'MediaLive (Live)',
locate({cs_uri_stem}, '/vod/') > 0, '',
'Other'
)
Visual 9: Cost Attribution per Channel (Stacked Bar)
- Visual type: Stacked bar chart
- X-axis:
channel_name - Value:
SumofBytes in GB(proxy for cost — multiply by your CF rate in calculated field) - Group/Color:
rendition - Sort: by total value descending
- Title: "Bandwidth Cost Attribution per Channel"
Calculated field for estimated weekly cost:
{Bytes in GB} * 0.085
Visual 10: Unused Renditions Alert Table
- Visual type: Table
- Columns:
channel_name,rendition,request_pct(calculated),estimated_cost - Conditional formatting: highlight rows where
request_pct< 3% in red - Title: "⚠️ Low-Usage Renditions (< 3% of channel traffic)"
- Sort: by
request_pctascending
Calculated field for request_pct within channel:
countOver({cs_uri_stem}, [{rendition}, {channel_name}], PRE_AGG)
/ countOver({cs_uri_stem}, [{channel_name}], PRE_AGG)
* 100
Tip: Pin the Per-Channel Breakdown and Alert Table visuals at the top of your dashboard for quick at-a-glance identification of optimization opportunities across all your encoding workflows.
8.4c Dashboard Layout with Multi-Source Views
┌──────────────────────────────────────────────────────────────────┐
│ [Date Range] [Rendition] [Source Service] [Channel] │
├──────────┬───────────────┬──────────────────┬────────────────────┤
│ KPI: │ KPI: │ KPI: │ KPI: │
│ Requests │ Data (GB) │ Unique Viewers │ Active Channels │
├──────────┴───────┬───────┴──────────────────┴────────────────────┤
│ Pie: Traffic │ Grouped Bar: Rendition per Channel │
│ │ │
├──────────────────┴───────────────────────────────────────────────┤
│ Stacked Bar: Cost Attribution per Channel │
├──────────────────────────────────────────────────────────────────┤
│ ⚠️ TABLE: Low-Usage Renditions (< 3% within channel) │
├──────────────────────────────────────────────────────────────────┤
│ Line Chart: Daily Trend by Rendition (all channels) │
├──────────────────────────────────────────────────────────────────┤
│ Heat Map: Edge Location × Rendition │
└──────────────────────────────────────────────────────────────────┘
8.5 Add Interactive Filters (Dashboard Controls)
Add a Date Range filter control at the top:
- Click Filter pane → Add filter → select
date - Set filter type: Date & time range → relative → default "Last 7 days"
- Click the filter → Add to sheet to make it an interactive date picker
Add a Rendition dropdown filter:
- Add filter → select
rendition→ filter type: Filter list - Click → Add to sheet → renders as a multi-select dropdown
- Default: all renditions selected
Add an Edge Location dropdown filter (optional):
- Add filter →
x_edge_location→ filter type: Filter list - Add to sheet → allows geographic drill-down
8.5b Add a Recommendations Visual
To surface optimization recommendations directly in the dashboard (so customers can see KEEP / ELIMINATE / REVIEW at a glance), add a dynamic recommendations field and a table visual:
Calculated field — Recommendation:
This uses a dynamic threshold — any rendition with less than 3% of traffic within its channel is flagged for elimination:
ifelse(
percentOfTotal(count({cs_uri_stem}), [{Rendition}]) < 3, '🔴 ELIMINATE',
percentOfTotal(count({cs_uri_stem}), [{Rendition}]) < 5, '🟡 REVIEW',
'🟢 KEEP'
)
Note: If
percentOfTotalis not available in your QuickSight edition, use a simpler static threshold based on your known data:ifelse( {Rendition} = '720p30_3.4M', '🔴 ELIMINATE (< 2% usage)', {Rendition} = '480p30_1.8M', '🔴 ELIMINATE (< 1% usage)', {Rendition} = '240p30_1.0M', '🟡 REVIEW (possible ABR startup only)', '🟢 KEEP' )
Calculated field — Potential_Savings:
ifelse(
{Rendition} = '720p30_3.4M', 90.72,
{Rendition} = '480p30_1.8M', 45.36,
{Rendition} = '240p30_1.0M', 23.52,
0
)
Adjust these values based on your actual MediaLive encoding cost per rendition per week.
Recommendations Table Visual
- Visual type: Table
- Columns:
Rendition, Count (as "Requests"),Bytes_GB(Sum),Recommendation,Potential_Savings - Sort: by
Recommendation(🔴 first, then 🟡, then 🟢) - Conditional formatting:-
Recommendationcolumn: red background for cells containing "ELIMINATE", yellow for "REVIEW", green for "KEEP" - Title: "⚠️ Rendition Optimization Recommendations"
Recommendations KPI Panel
Add a KPI visual showing total potential weekly savings:
- Value:
SumofPotential_Savings - Title: "Potential Weekly Savings"
- Comparison: show as "$amount/week" using number formatting (prefix: $, suffix: /week)
Final Dashboard Layout with Recommendations
┌──────────────────────────────────────────────────────────────────┐
│ [Date Range] [Rendition] [Edge Location] │
├──────────┬───────────────┬──────────────────┬────────────────────┤
│ KPI: │ KPI: │ KPI: │ KPI: │
│ Requests │ Data (GB) │ Unique Viewers │ 💰 Savings/week │
├──────────┴───────────────┴──────────────────┴────────────────────┤
│ ⚠️ TABLE: Rendition Recommendations (KEEP / ELIMINATE / REVIEW) │
├──────────────────┬───────────────────────────────────────────────┤
│ Donut: Request % │ Bar: Bandwidth per Rendition │
│ by Rendition │ │
├──────────────────┴───────────────────────────────────────────────┤
│ Line Chart: Daily Trend by Rendition │
├──────────────────────────────────────────────────────────────────┤
│ Bar: Edge Location Volume │ Donut: Cache Hit/Miss │
├──────────────────────────────────────────────────────────────────┤
│ Bar: Hourly Pattern │ Bar: Bytes by Rendition + Success │
└──────────────────────────────────────────────────────────────────┘
Tip: Place the Recommendations table at the TOP of the dashboard — it's the most actionable insight and the first thing a customer should see.
8.6 Publish as Dashboard
- In the Analysis, click Share → Publish Dashboard
- Dashboard name:
HLS Rendition Usage Advisor - Set permissions:- Share with specific users/groups if you want team access- Or keep Private for personal use
- Click Publish
8.7 Configure Automatic SPICE Data Refresh
To keep the dashboard up to date without manual intervention:
- Go to Datasets → select
cloudfront-hls-rendition-logs - Click the Refresh tab → Add new schedule
- Configure:- Frequency: Daily- Starting at:
06:00 UTC(adjust to run before your morning review)- Time zone: Select your timezone- Refresh type: Full refresh (or Incremental if you've partitioned by date — reduces SPICE scan cost) - Click Create
SPICE cost note: For 50K–100K new records per week, SPICE usage fits comfortably within the free tier (1 GB free per author). At scale (millions of records/month), consider switching to Direct Query mode — it queries Athena live instead of importing to SPICE, avoiding SPICE storage costs but with slightly slower dashboard load times.
8.8 QuickSight Dashboard Layout Reference
┌──────────────────────────────────────────────────────────────────┐
│ [Date Range Filter] [Rendition Filter] [Edge Location Filter] │
├──────────┬───────────────┬───────────────────────────────────────┤
│ KPI: │ KPI: │ KPI: │
│ Requests │ Data (GB) │ Unique Viewers │
├──────────┴───────┬───────┴───────────────────────────────────────┤
│ Donut Chart: │ Horizontal Bar Chart: │
│ Request % │ Bandwidth per Rendition │
│ by Rendition │ │
├──────────────────┴───────────────────────────────────────────────┤
│ Line Chart: Daily Trend by Rendition │
├──────────────────────────────────────────────────────────────────┤
│ Heat Map: Edge Location × Rendition │
├──────────────────────────────────────────────────────────────────┤
│ Stacked Area: Hourly Pattern │
└──────────────────────────────────────────────────────────────────┘
Example Results Interpretation
After running this analysis on a production channel, you might see results like:
| Rendition | Requests | % Total | Bandwidth | Weekly Cost | Recommendation |
|---|---|---|---|---|---|
| 720p | 22,549 | 45.1% | 7.44 GB | $91.35 | ✅ KEEP — primary rendition |
| 480p | 12,517 | 25.0% | 2.04 GB | $45.53 | ✅ KEEP — strong mobile usage |
| 1080p | 7,459 | 14.9% | 4.97 GB | $181.86 | ⚡ OPTIMIZE — reduce bitrate |
| 360p | 5,001 | 10.0% | 0.44 GB | $23.56 | ✅ KEEP — serves low-bandwidth users |
| 240p | 1,428 | 2.9% | 0.06 GB | $11.77 | ❌ ELIMINATE — save $612/year |
| 1080p_alt | 1,046 | 2.1% | 0.53 GB | $181.49 | ❌ ELIMINATE — save $9,437/year |
Key insight — Cost optimization: In this example, two renditions (240p and 1080p_alt) serve less than 5% of viewers combined but account for 36% of total encoding costs. Removing them saves over $10,000/year with minimal viewer impact.
Key insight — Viewer experience: If 720p accounts for 45% of all requests, your audience has bandwidth for HD content. This signals an opportunity to:
- Add a 1080p rendition — viewers consuming 720p on high-bandwidth connections will automatically upgrade, improving their experience
- Optimize the 720p bitrate ladder — add a step between 720p@3Mbps and 1080p@5Mbps to reduce buffering during ABR transitions
- Reallocate encoding budget — the savings from eliminating 240p and 1080p_alt ($10K/year) can fund the additional 1080p rendition with better quality
Best Practices
- Analyze at least 7 days of data — daily and hourly patterns vary significantly. Weekend vs. weekday and prime-time vs. off-peak can shift rendition preferences.
- Segment by geography — viewers in regions with lower average bandwidth (emerging markets) may rely heavily on lower renditions. Eliminating a low-usage rendition globally might impact specific regions disproportionately.
- Consider player ABR logic — some players briefly request a low rendition on startup before switching up. Filter out very short sessions (< 3 segments from same IP) to avoid counting these as sustained usage.
- Re-run after changes — after eliminating or adding renditions, wait 2–4 weeks and re-analyze. ABR players will redistribute to available renditions, and you'll see the new equilibrium.
- Factor in content type — sports and high-motion content benefits more from higher bitrates than talk shows or news. Consider per-channel analysis rather than global averages.
- Monitor CloudWatch MediaLive metrics — after removing a rendition, watch
Output4xxErrorsandOutputMediaDataMissingto confirm no negative viewer impact.
Conclusion
By correlating CloudFront access patterns with your MediaLive encoding configuration, you can make data-driven decisions about your ABR rendition ladder. Common operational patterns show that most deployments can safely eliminate 1–2 underused renditions, typically saving 15–35% on encoding costs while maintaining or improving viewer experience.
This approach works for any customer using the MediaLive → CloudFront delivery pattern. Future iterations could extend to MediaPackage origins or incorporate real-time ABR session analytics from player-side telemetry.
Related Resources
- Amazon CloudFront Standard Logging
- AWS Elemental MediaLive Output Groups
- Querying CloudFront Logs with Athena
- AWS Pricing Calculator — MediaLive
- HLS Authoring Specification for Apple Devices
## CFN Template
AWSTemplateFormatVersion: '2010-09-09'
Description: >
HLS Rendition Usage Advisor — Analysis Pipeline
Deploys the monitoring and analysis layer for correlating CloudFront access logs
with MediaLive/MediaConvert HLS rendition configurations.
Prerequisite: You already have MediaLive/MediaConvert outputting to S3 with CloudFront serving the content.
# =============================================================================
# PARAMETERS — Customer fills in their existing resource IDs
# =============================================================================
Parameters:
CloudFrontDistributionId:
Type: String
Description: 'Your existing CloudFront distribution ID (e.g., E1ABC2DEF3GHIJ)'
AllowedPattern: 'E[A-Z0-9]+'
ConstraintDescription: 'Must be a valid CloudFront distribution ID starting with E'
ChannelName:
Type: String
Default: 'channel1'
Description: 'Friendly name for your MediaLive/MediaConvert channel (used in log prefix and naming)'
BucketPrefix:
Type: String
Default: 'rendition-advisor'
Description: 'Prefix for S3 bucket names (must be lowercase, no uppercase characters)'
AllowedPattern: '[a-z0-9][a-z0-9.-]*'
ConstraintDescription: 'Must be lowercase alphanumeric, hyphens, or dots (S3 naming rules)'
DatabaseName:
Type: String
Default: 'rendition_advisor_db'
Description: 'Glue/Athena database name (lowercase, alphanumeric and underscores only — no hyphens)'
AllowedPattern: '[a-z0-9_]+'
ConstraintDescription: 'Must contain only lowercase letters, numbers, and underscores'
NotificationEmail:
Type: String
Default: ''
Description: '(Optional) Email address for weekly rendition report notifications. Leave blank to skip.'
LogRetentionDays:
Type: Number
Default: 90
Description: 'Days to retain CloudFront access logs before auto-deletion (cost optimization)'
AllowedValues: [30, 60, 90, 180, 365]
ExistingLogBucket:
Type: String
Default: ''
Description: >
(Optional) If CloudFront is already logging to an S3 bucket, enter the bucket name here.
The template will point Athena directly at your existing logs — no new bucket created.
Leave blank to create a new dedicated log bucket.
ExistingLogPrefix:
Type: String
Default: ''
Description: >
(Optional) Prefix in the existing bucket where CF logs are stored (e.g., "cf-logs/distribution-id/").
Only used when ExistingLogBucket is provided.
LogType:
Type: String
Default: 'standard'
AllowedValues: ['standard', 'realtime-v2']
Description: >
Type of CloudFront logs in the bucket.
"standard" = CloudFront Standard Logging (v1).
"realtime-v2" = Real-time logs delivered to S3 via Kinesis Data Firehose.
EnableWeeklyAutomation:
Type: String
Default: 'true'
AllowedValues: ['true', 'false']
Description: 'Deploy the Lambda + EventBridge weekly automation? Set false for manual-only analysis.'
Conditions:
HasNotificationEmail: !Not [!Equals [!Ref NotificationEmail, '']]
DeployAutomation: !Equals [!Ref EnableWeeklyAutomation, 'true']
CreateNewLogBucket: !Equals [!Ref ExistingLogBucket, '']
UseExistingBucket: !Not [!Equals [!Ref ExistingLogBucket, '']]
IsRealtimeLogs: !Equals [!Ref LogType, 'realtime-v2']
# =============================================================================
# RESOURCES
# =============================================================================
Resources:
# ---------------------------------------------------------------------------
# S3 Bucket: CloudFront Access Logs (only created if no existing bucket provided)
# ---------------------------------------------------------------------------
CloudFrontLogsBucket:
Type: AWS::S3::Bucket
Condition: CreateNewLogBucket
Properties:
BucketName: !Sub '${BucketPrefix}-cf-logs-${AWS::AccountId}'
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerPreferred
LifecycleConfiguration:
Rules:
- Id: AutoExpireLogs
Status: Enabled
ExpirationInDays: !Ref LogRetentionDays
Tags:
- Key: Project
Value: HLS-Rendition-Advisor
- Key: Channel
Value: !Ref ChannelName
# Bucket policy to allow CloudFront to write logs (only if creating new bucket)
CloudFrontLogsBucketPolicy:
Type: AWS::S3::BucketPolicy
Condition: CreateNewLogBucket
Properties:
Bucket: !Ref CloudFrontLogsBucket
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: AllowCloudFrontLogging
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action: 's3:PutObject'
Resource: !Sub '${CloudFrontLogsBucket.Arn}/*'
Condition:
StringEquals:
aws:SourceAccount: !Ref AWS::AccountId
# ---------------------------------------------------------------------------
# S3 Bucket: Athena Query Results
# ---------------------------------------------------------------------------
AthenaResultsBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub '${BucketPrefix}-athena-results-${AWS::AccountId}'
LifecycleConfiguration:
Rules:
- Id: ExpireQueryResults
Status: Enabled
ExpirationInDays: 7
Tags:
- Key: Project
Value: HLS-Rendition-Advisor
# ---------------------------------------------------------------------------
# S3 Bucket: Rendition Lookup Data (from manifest parsing)
# ---------------------------------------------------------------------------
RenditionLookupBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub '${BucketPrefix}-rendition-lookup-${AWS::AccountId}'
Tags:
- Key: Project
Value: HLS-Rendition-Advisor
# ---------------------------------------------------------------------------
# Glue Database
# ---------------------------------------------------------------------------
GlueDatabase:
Type: AWS::Glue::Database
Properties:
CatalogId: !Ref AWS::AccountId
DatabaseInput:
Name: !Ref DatabaseName
Description: 'HLS Rendition Usage Advisor — CloudFront log analytics'
# ---------------------------------------------------------------------------
# Glue Table: CloudFront Access Logs
# ---------------------------------------------------------------------------
CloudFrontLogsTable:
Type: AWS::Glue::Table
DependsOn: GlueDatabase
Properties:
CatalogId: !Ref AWS::AccountId
DatabaseName: !Ref DatabaseName
TableInput:
Name: cloudfront_hls_logs
Description: 'CloudFront standard access logs for HLS rendition analysis'
TableType: EXTERNAL_TABLE
Parameters:
'skip.header.line.count': '2'
classification: csv
StorageDescriptor:
Location: !If
- UseExistingBucket
- !Sub 's3://${ExistingLogBucket}/${ExistingLogPrefix}'
- !Sub 's3://${CloudFrontLogsBucket}/${ChannelName}/'
InputFormat: org.apache.hadoop.mapred.TextInputFormat
OutputFormat: org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat
SerdeInfo:
SerializationLibrary: org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe
Parameters:
'field.delim': "\t"
'serialization.format': "\t"
Columns:
- Name: date
Type: date
- Name: time
Type: string
- Name: x_edge_location
Type: string
- Name: sc_bytes
Type: bigint
- Name: c_ip
Type: string
- Name: cs_method
Type: string
- Name: cs_host
Type: string
- Name: cs_uri_stem
Type: string
- Name: sc_status
Type: int
- Name: cs_referer
Type: string
- Name: cs_user_agent
Type: string
- Name: cs_uri_query
Type: string
- Name: cs_cookie
Type: string
- Name: x_edge_result_type
Type: string
- Name: x_edge_request_id
Type: string
- Name: x_host_header
Type: string
- Name: cs_protocol
Type: string
- Name: cs_bytes
Type: bigint
- Name: time_taken
Type: float
- Name: x_forwarded_for
Type: string
- Name: ssl_protocol
Type: string
- Name: ssl_cipher
Type: string
- Name: x_edge_response_result_type
Type: string
- Name: cs_protocol_version
Type: string
- Name: fle_status
Type: string
- Name: fle_encrypted_fields
Type: int
- Name: c_port
Type: int
- Name: time_to_first_byte
Type: float
- Name: x_edge_detailed_result_type
Type: string
- Name: sc_content_type
Type: string
- Name: sc_content_len
Type: bigint
- Name: sc_range_start
Type: bigint
- Name: sc_range_end
Type: bigint
# ---------------------------------------------------------------------------
# Glue Table: CloudFront Real-Time Logs v2 (conditional — only if LogType=realtime-v2)
# ---------------------------------------------------------------------------
CloudFrontRealtimeLogsTable:
Type: AWS::Glue::Table
Condition: IsRealtimeLogs
DependsOn: GlueDatabase
Properties:
CatalogId: !Ref AWS::AccountId
DatabaseName: !Ref DatabaseName
TableInput:
Name: cloudfront_realtime_logs
Description: 'CloudFront real-time logs (v2) delivered via Kinesis Data Firehose'
TableType: EXTERNAL_TABLE
Parameters:
classification: json
StorageDescriptor:
Location: !If
- UseExistingBucket
- !Sub 's3://${ExistingLogBucket}/${ExistingLogPrefix}'
- !Sub 's3://${CloudFrontLogsBucket}/${ChannelName}/'
InputFormat: org.apache.hadoop.mapred.TextInputFormat
OutputFormat: org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat
SerdeInfo:
SerializationLibrary: org.openx.data.jsonserde.JsonSerDe
Columns:
- Name: timestamp
Type: bigint
Comment: 'Unix epoch in milliseconds'
- Name: c-ip
Type: string
- Name: sc-status
Type: int
- Name: cs-uri-stem
Type: string
- Name: sc-bytes
Type: bigint
- Name: cs-user-agent
Type: string
- Name: x-edge-location
Type: string
- Name: x-edge-result-type
Type: string
- Name: cs-host
Type: string
- Name: time-taken
Type: float
- Name: sc-content-type
Type: string
- Name: sc-range-start
Type: bigint
- Name: sc-range-end
Type: bigint
# ---------------------------------------------------------------------------
# Glue Table: Rendition Lookup (from HLS manifest parsing)
# ---------------------------------------------------------------------------
RenditionLookupTable:
Type: AWS::Glue::Table
DependsOn: GlueDatabase
Properties:
CatalogId: !Ref AWS::AccountId
DatabaseName: !Ref DatabaseName
TableInput:
Name: rendition_lookup
Description: 'Rendition mapping from HLS parent manifest parsing'
TableType: EXTERNAL_TABLE
Parameters:
classification: json
StorageDescriptor:
Location: !Sub 's3://${RenditionLookupBucket}/lookup/'
InputFormat: org.apache.hadoop.mapred.TextInputFormat
OutputFormat: org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat
SerdeInfo:
SerializationLibrary: org.openx.data.jsonserde.JsonSerDe
Columns:
- Name: uri_prefix
Type: string
- Name: resolution
Type: string
- Name: bandwidth
Type: bigint
- Name: label
Type: string
# ---------------------------------------------------------------------------
# Athena Workgroup
# ---------------------------------------------------------------------------
AthenaWorkgroup:
Type: AWS::Athena::WorkGroup
Properties:
Name: !Sub '${AWS::StackName}-workgroup'
Description: 'HLS Rendition Usage Advisor queries'
WorkGroupConfiguration:
ResultConfiguration:
OutputLocation: !Sub 's3://${AthenaResultsBucket}/results/'
EnforceWorkGroupConfiguration: true
PublishCloudWatchMetricsEnabled: true
# ---------------------------------------------------------------------------
# Athena Named Queries (ready to run from the console)
# ---------------------------------------------------------------------------
QueryRenditionDistribution:
Type: AWS::Athena::NamedQuery
Properties:
Name: 'Rendition Usage Distribution'
Description: 'Shows request count, bandwidth, and viewer percentage per rendition'
Database: !Ref DatabaseName
WorkGroup: !Ref AthenaWorkgroup
QueryString: !Sub |
-- HLS Rendition Usage Distribution (last 7 days)
SELECT
COALESCE(
REGEXP_EXTRACT(cs_uri_stem, '/([0-9]+p[^/]*|[0-9]+k[^/]*)/', 1),
REGEXP_EXTRACT(cs_uri_stem, '(index_[0-9]+)', 1)
) AS rendition,
COUNT(*) AS segment_requests,
ROUND(SUM(sc_bytes) / POWER(1024, 3), 2) AS total_gb,
COUNT(DISTINCT c_ip) AS unique_viewers,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) AS request_pct
FROM cloudfront_hls_logs
WHERE cs_uri_stem LIKE '%.ts'
AND sc_status = 200
AND "date" >= CURRENT_DATE - INTERVAL '7' DAY
GROUP BY COALESCE(
REGEXP_EXTRACT(cs_uri_stem, '/([0-9]+p[^/]*|[0-9]+k[^/]*)/', 1),
REGEXP_EXTRACT(cs_uri_stem, '(index_[0-9]+)', 1)
)
ORDER BY segment_requests DESC;
QueryRenditionWithLookup:
Type: AWS::Athena::NamedQuery
Properties:
Name: 'Rendition Usage (with Manifest Lookup)'
Description: 'For channels with generic output names — joins with rendition_lookup table'
Database: !Ref DatabaseName
WorkGroup: !Ref AthenaWorkgroup
QueryString: !Sub |
-- HLS Rendition Usage with Manifest-Based Lookup (last 7 days)
SELECT
rl.label AS rendition,
rl.resolution,
ROUND(rl.bandwidth / 1000000.0, 1) AS bitrate_mbps,
COUNT(*) AS segment_requests,
ROUND(SUM(cf.sc_bytes) / POWER(1024, 3), 2) AS total_gb,
COUNT(DISTINCT cf.c_ip) AS unique_viewers,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) AS request_pct
FROM cloudfront_hls_logs cf
JOIN rendition_lookup rl
ON REGEXP_EXTRACT(cf.cs_uri_stem, '(index_[0-9]+|[^/]+)(?=_[0-9]+\.ts|\.ts)', 1) = rl.uri_prefix
WHERE cf.cs_uri_stem LIKE '%.ts'
AND cf.sc_status = 200
AND cf."date" >= CURRENT_DATE - INTERVAL '7' DAY
GROUP BY rl.label, rl.resolution, rl.bandwidth
ORDER BY segment_requests DESC;
QueryHourlyBreakdown:
Type: AWS::Athena::NamedQuery
Properties:
Name: 'Rendition Usage by Hour'
Description: 'Identifies peak and off-peak rendition consumption patterns'
Database: !Ref DatabaseName
WorkGroup: !Ref AthenaWorkgroup
QueryString: !Sub |
-- Hourly Rendition Usage Pattern
SELECT
REGEXP_EXTRACT(cs_uri_stem, '/([0-9]+p[^/]*)/', 1) AS rendition,
SUBSTR("time", 1, 2) AS hour_of_day,
COUNT(*) AS requests,
ROUND(SUM(sc_bytes) / POWER(1024, 3), 3) AS gb
FROM cloudfront_hls_logs
WHERE cs_uri_stem LIKE '%.ts'
AND sc_status = 200
AND "date" >= CURRENT_DATE - INTERVAL '7' DAY
GROUP BY 1, 2
ORDER BY rendition, hour_of_day;
QueryGeographicDistribution:
Type: AWS::Athena::NamedQuery
Properties:
Name: 'Rendition Usage by Edge Location'
Description: 'Geographic distribution of rendition requests'
Database: !Ref DatabaseName
WorkGroup: !Ref AthenaWorkgroup
QueryString: !Sub |
-- Geographic Distribution of Rendition Requests
SELECT
REGEXP_EXTRACT(cs_uri_stem, '/([0-9]+p[^/]*)/', 1) AS rendition,
x_edge_location,
COUNT(*) AS requests,
ROUND(SUM(sc_bytes) / POWER(1024, 3), 3) AS total_gb
FROM cloudfront_hls_logs
WHERE cs_uri_stem LIKE '%.ts'
AND sc_status = 200
AND "date" >= CURRENT_DATE - INTERVAL '7' DAY
GROUP BY 1, 2
HAVING COUNT(*) > 100
ORDER BY rendition, requests DESC;
# ---------------------------------------------------------------------------
# SNS Topic for Notifications (conditional)
# ---------------------------------------------------------------------------
NotificationTopic:
Type: AWS::SNS::Topic
Condition: HasNotificationEmail
Properties:
TopicName: !Sub '${AWS::StackName}-notifications'
Tags:
- Key: Project
Value: HLS-Rendition-Advisor
NotificationSubscription:
Type: AWS::SNS::Subscription
Condition: HasNotificationEmail
Properties:
TopicArn: !Ref NotificationTopic
Protocol: email
Endpoint: !Ref NotificationEmail
# ---------------------------------------------------------------------------
# Lambda: Weekly Rendition Analysis (conditional)
# ---------------------------------------------------------------------------
AnalysisLambdaRole:
Type: AWS::IAM::Role
Condition: DeployAutomation
Properties:
RoleName: !Sub '${AWS::StackName}-lambda-role'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: RenditionAnalysisPolicy
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- athena:StartQueryExecution
- athena:GetQueryExecution
- athena:GetQueryResults
Resource: '*'
- Effect: Allow
Action:
- s3:GetObject
- s3:PutObject
- s3:GetBucketLocation
- s3:ListBucket
Resource:
- !GetAtt AthenaResultsBucket.Arn
- !Sub '${AthenaResultsBucket.Arn}/*'
- Effect: Allow
Action:
- s3:GetObject
- s3:ListBucket
Resource:
- !If
- UseExistingBucket
- !Sub 'arn:aws:s3:::${ExistingLogBucket}'
- !GetAtt CloudFrontLogsBucket.Arn
- !If
- UseExistingBucket
- !Sub 'arn:aws:s3:::${ExistingLogBucket}/*'
- !Sub '${CloudFrontLogsBucket.Arn}/*'
- Effect: Allow
Action:
- s3:GetObject
- s3:ListBucket
- s3:GetBucketLocation
Resource:
- !GetAtt RenditionLookupBucket.Arn
- !Sub '${RenditionLookupBucket.Arn}/*'
- Effect: Allow
Action:
- glue:GetTable
- glue:GetDatabase
Resource: '*'
- !If
- HasNotificationEmail
- Effect: Allow
Action: sns:Publish
Resource: !Ref NotificationTopic
- !Ref AWS::NoValue
- Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Resource: 'arn:aws:logs:*:*:*'
AnalysisLambdaFunction:
Type: AWS::Lambda::Function
Condition: DeployAutomation
Properties:
FunctionName: !Sub '${AWS::StackName}-weekly-analysis'
Runtime: python3.12
Handler: index.handler
Role: !GetAtt AnalysisLambdaRole.Arn
Timeout: 300
MemorySize: 256
Environment:
Variables:
DATABASE_NAME: !Ref DatabaseName
RESULTS_BUCKET: !Ref AthenaResultsBucket
WORKGROUP: !Ref AthenaWorkgroup
SNS_TOPIC_ARN: !If [HasNotificationEmail, !Ref NotificationTopic, '']
Code:
ZipFile: |
import boto3
import json
import os
import time
from datetime import datetime, timedelta
athena = boto3.client('athena')
sns = boto3.client('sns')
def handler(event, context):
database = os.environ['DATABASE_NAME']
workgroup = os.environ['WORKGROUP']
results_bucket = os.environ['RESULTS_BUCKET']
sns_topic = os.environ.get('SNS_TOPIC_ARN', '')
# Query: rendition distribution for last 7 days
query = """
SELECT
COALESCE(
REGEXP_EXTRACT(cs_uri_stem, '/([0-9]+p[^/]*|[0-9]+k[^/]*)/', 1),
REGEXP_EXTRACT(cs_uri_stem, '(index_[0-9]+)', 1)
) AS rendition,
COUNT(*) AS segment_requests,
ROUND(SUM(sc_bytes) / POWER(1024, 3), 2) AS total_gb,
COUNT(DISTINCT c_ip) AS unique_viewers,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) AS request_pct
FROM cloudfront_hls_logs
WHERE cs_uri_stem LIKE '%.ts'
AND sc_status = 200
AND "date" >= CURRENT_DATE - INTERVAL '7' DAY
GROUP BY COALESCE(
REGEXP_EXTRACT(cs_uri_stem, '/([0-9]+p[^/]*|[0-9]+k[^/]*)/', 1),
REGEXP_EXTRACT(cs_uri_stem, '(index_[0-9]+)', 1)
)
ORDER BY segment_requests DESC
"""
# Execute query
response = athena.start_query_execution(
QueryString=query,
QueryExecutionContext={'Database': database},
WorkGroup=workgroup
)
query_id = response['QueryExecutionId']
# Poll for completion
while True:
status = athena.get_query_execution(QueryExecutionId=query_id)
state = status['QueryExecution']['Status']['State']
if state in ('SUCCEEDED', 'FAILED', 'CANCELLED'):
break
time.sleep(2)
if state != 'SUCCEEDED':
raise Exception(f"Query failed: {state}")
# Get results
results = athena.get_query_results(QueryExecutionId=query_id)
rows = results['ResultSet']['Rows'][1:] # skip header
# Build report
report_lines = ["HLS Rendition Usage Report", "=" * 40, ""]
report_lines.append(f"Period: Last 7 days (ending {datetime.now().strftime('%Y-%m-%d')})")
report_lines.append("")
report_lines.append(f"{'Rendition':<12} {'Requests':>10} {'GB':>8} {'Viewers':>8} {'%':>7}")
report_lines.append("-" * 50)
low_usage = []
for row in rows:
data = [col.get('VarCharValue', '') for col in row['Data']]
rendition, requests, gb, viewers, pct = data
report_lines.append(f"{rendition:<12} {requests:>10} {gb:>8} {viewers:>8} {pct:>6}%")
if float(pct) < 3.0:
low_usage.append(rendition)
report_lines.append("")
if low_usage:
report_lines.append("⚠️ RECOMMENDATIONS:")
for r in low_usage:
report_lines.append(f" - ELIMINATE '{r}' (< 3% usage)")
else:
report_lines.append("✅ All renditions have healthy usage (> 3%)")
report = "\n".join(report_lines)
print(report)
# Send notification if configured
if sns_topic:
sns.publish(
TopicArn=sns_topic,
Subject='Weekly HLS Rendition Usage Report',
Message=report
)
return {'statusCode': 200, 'report': report}
# ---------------------------------------------------------------------------
# EventBridge: Weekly Schedule (conditional)
# ---------------------------------------------------------------------------
WeeklyScheduleRule:
Type: AWS::Events::Rule
Condition: DeployAutomation
Properties:
Name: !Sub '${AWS::StackName}-weekly-analysis'
Description: 'Triggers weekly rendition usage analysis every Monday at 06:00 UTC'
ScheduleExpression: 'cron(0 6 ? * MON *)'
State: ENABLED
Targets:
- Arn: !GetAtt AnalysisLambdaFunction.Arn
Id: WeeklyRenditionAnalysis
LambdaInvokePermission:
Type: AWS::Lambda::Permission
Condition: DeployAutomation
Properties:
FunctionName: !Ref AnalysisLambdaFunction
Action: lambda:InvokeFunction
Principal: events.amazonaws.com
SourceArn: !GetAtt WeeklyScheduleRule.Arn
# =============================================================================
# OUTPUTS
# =============================================================================
Outputs:
LogBucketName:
Description: 'S3 bucket for CloudFront access logs. If you provided an existing bucket, this shows that. Otherwise, enable logging on your distribution pointing to the new bucket.'
Value: !If
- UseExistingBucket
- !Sub '${ExistingLogBucket} (existing — already configured)'
- !Ref CloudFrontLogsBucket
LogPrefix:
Description: 'Use this prefix when configuring CloudFront logging'
Value: !If
- UseExistingBucket
- !Sub '${ExistingLogPrefix} (existing prefix — no change needed)'
- !Sub '${ChannelName}/'
AthenaDatabase:
Description: 'Athena database name — use this in the Athena console'
Value: !Ref DatabaseName
AthenaWorkgroup:
Description: 'Athena workgroup — select this when running queries'
Value: !Ref AthenaWorkgroup
RenditionLookupBucket:
Description: 'Upload your manifest-parsed rendition lookup JSON here (at /lookup/ prefix)'
Value: !Sub 's3://${RenditionLookupBucket}/lookup/'
NextStep:
Condition: CreateNewLogBucket
Description: 'Post-deployment action (new bucket created)'
Value: !Sub >
1. Enable Standard Logging on CloudFront distribution ${CloudFrontDistributionId}
pointing to bucket ${BucketPrefix}-cf-logs-${AWS::AccountId} with prefix ${ChannelName}/
2. Wait 30 min for logs to appear
3. Go to Athena Console → select workgroup ${AWS::StackName}-workgroup
4. Run the pre-loaded "Rendition Usage Distribution" named query
5. (Optional) Upload rendition_lookup.json to s3://${RenditionLookupBucket}/lookup/
NextStepExisting:
Condition: UseExistingBucket
Description: 'Post-deployment action (using existing log bucket)'
Value: !Sub >
Your existing bucket "${ExistingLogBucket}" with prefix "${ExistingLogPrefix}" is already
connected to Athena. No CloudFront logging changes needed!
1. Go to Athena Console → select workgroup ${AWS::StackName}-workgroup
2. Run the pre-loaded "Rendition Usage Distribution" named query
3. Historical data is immediately available for analysis
4. (Optional) Upload rendition_lookup.json to s3://${RenditionLookupBucket}/lookup/
```
I think it's not so straight, as some streams in abr ladder exist to compensate network network congestion and used really rare but still useful
replied 11 days ago
Hi Roman, The question I keep asking it is: "Do I know what my viewers are actually consuming, or am I just guessing?"
Once you have the data, you can make informed calls — maybe you keep that 240p fallback because 6 unique viewers needed it during peak congestion, or maybe you discover that a redundant 720p30 variant is costing you encoding budget that could go toward adding a 1080p rendition your viewers clearly have bandwidth for.
The goal is to review your ladder with data so you can give viewers better quality where it matters, not just cut costs. It's an example of how you can use the approach to evaluate and improve — each customer's decision will look different based on their audience. 📊
Relevant content
asked 3 years ago
asked 4 years ago
AWS OFFICIALUpdated 2 years ago
AWS OFFICIALUpdated 4 months ago