from datetime import datetime, timedelta, timezone from typing import Dict, List, Optional import json import os import requests import dagster as dg from dagster import AssetKey, DagsterInstance # Slack configuration from environment variables SLACK_TOKEN = os.environ.get("SLACK_TOKEN", "") SLACK_CHANNEL = os.environ.get("SLACK_CHANNEL", "data-alerts") # SLA configuration - hourly jobs should complete within 1 hours SLA_DEADLINE_HOURS = 1 # Asset filter configuration # BLACKLIST: Exclude these assets from monitoring (exact match or prefix) # Leave empty [] to monitor all assets ASSET_BLACKLIST = [ # Examples: # "test_asset", # Exclude specific asset # "temp_", # Exclude all assets starting with "temp_" # "rec_hook_hashtags", # Exclude this specific asset ] def _is_asset_monitored(asset_name: str, blacklist: List[str]) -> bool: """ Check if an asset should be monitored based on blacklist. Args: asset_name: Name of the asset to check blacklist: List of asset names or prefixes to exclude Returns: True if asset should be monitored, False if blacklisted """ # Check if asset matches any blacklist pattern for pattern in blacklist: if asset_name == pattern or asset_name.startswith(pattern): return False # Not in blacklist, monitor it return True def _discover_hourly_partitioned_assets(context: dg.SensorEvaluationContext, target_datetime, logger, blacklist=None): """ Auto-discover hourly partitioned assets from Dagster definitions with blacklist filtering. Args: context: Sensor evaluation context target_datetime: Target datetime to check logger: Logger instance blacklist: List of asset names/prefixes to exclude (None = use ASSET_BLACKLIST) """ if blacklist is None: blacklist = ASSET_BLACKLIST hourly_assets = [] filtered_assets = [] all_assets = context.repository_def.assets_defs_by_key logger.info(f"🔍 Auto-discovery: Starting hourly asset discovery") if blacklist: logger.info(f"🚫 Blacklist enabled: {blacklist}") for asset_key, asset_def in all_assets.items(): if hasattr(asset_def, 'partitions_def') and asset_def.partitions_def: # Check if it's an hourly partitions definition if isinstance(asset_def.partitions_def, dg.HourlyPartitionsDefinition): asset_name = asset_key.to_user_string() # Check if asset should be monitored if not _is_asset_monitored(asset_name, blacklist): filtered_assets.append(asset_name) logger.debug(f"⏭️ Filtered out: {asset_name}") continue # Get start date from partition definition start_datetime = asset_def.partitions_def.start # Skip if target datetime is before asset start datetime if target_datetime < start_datetime: logger.debug(f"Skipping {asset_name}: target datetime {target_datetime} before start {start_datetime}") continue hourly_assets.append({ "asset_key": asset_key, "asset_name": asset_name, "expected_schedule": f"Hourly (auto-discovered)", "start_datetime": start_datetime.strftime("%Y-%m-%d %H:%M:%S") }) logger.info(f"✅ Auto-discovered hourly asset: {asset_name} (start: {start_datetime})") logger.info(f"🔍 Auto-discovery: Found {len(hourly_assets)} monitored assets (filtered out {len(filtered_assets)})") return hourly_assets @dg.sensor( name="hourly_jobs_sla_monitor", description="Automatically discovers and monitors ALL hourly partitioned assets for missing partitions", minimum_interval_seconds=3600, # Check every 1 hour metadata={ "expected_data_cadence": "hourly", "sla_deadline_hours": SLA_DEADLINE_HOURS, "slack_channel": SLACK_CHANNEL, "discovery_method": "automatic_with_fallback", } ) def hourly_jobs_sla_monitor(context: dg.SensorEvaluationContext) -> dg.SensorResult: """ General sensor that monitors ALL hourly partitioned assets for missing partitions. Sends a consolidated Slack alert if any hourly jobs didn't run for the previous hour's partition. """ try: logger = context.log current_utc = datetime.now(timezone.utc) # Check the hour that was SLA_DEADLINE_HOURS ago (e.g., 2 hours ago) target_datetime = current_utc - timedelta(hours=SLA_DEADLINE_HOURS) # Round to the hour target_datetime = target_datetime.replace(minute=0, second=0, microsecond=0) target_partition_key = target_datetime.strftime("%Y-%m-%d-%H:00") logger.info(f"Checking hourly jobs for partition: {target_partition_key}") logger.info(f"Current UTC: {current_utc.strftime('%Y-%m-%d %H:%M:%S')}") logger.info(f"Target datetime: {target_datetime.strftime('%Y-%m-%d %H:%M:%S')}") instance = context.instance # Auto-discover hourly partitioned assets from Dagster hourly_assets = _discover_hourly_partitioned_assets(context, target_datetime, logger) logger.info(f"Monitoring {len(hourly_assets)} hourly partitioned assets") # Check materialization status for each asset missing_assets = [] checked_assets = [] for asset_info in hourly_assets: asset_key = asset_info["asset_key"] asset_name = asset_info["asset_name"] checked_assets.append(asset_name) # Check if this asset has materialization for the target partition has_materialization = _check_asset_materialization( instance, asset_key, asset_name, target_partition_key, logger ) if not has_materialization: missing_assets.append({ "asset_name": asset_name, "asset_key": asset_key, "partition_key": target_partition_key, "expected_schedule": asset_info["expected_schedule"], "status": "missing_materialization", "hours_overdue": SLA_DEADLINE_HOURS }) logger.info(f"Checked {len(checked_assets)} assets, found {len(missing_assets)} missing partitions") if missing_assets: # Send consolidated alert for all missing partitions alert_message = _create_consolidated_alert_message( missing_assets, target_partition_key, current_utc, target_datetime ) # Uncomment to enable Slack alerts success = _send_slack_alert(alert_message, logger) success = False # Set default since Slack alert is disabled logger.error(f"Missing partitions found: {len(missing_assets)} assets") return dg.SensorResult( cursor_result=target_partition_key, metadata={ "partition_checked": target_partition_key, "target_hour": target_datetime.strftime("%Y-%m-%d %H:00"), "total_assets_checked": len(hourly_assets), "missing_assets_count": len(missing_assets), "missing_assets": [asset["asset_name"] for asset in missing_assets], "alert_sent": success, "check_time": current_utc.isoformat(), "sla_hours": SLA_DEADLINE_HOURS } ) else: # All assets have their partitions logger.info(f"All {len(hourly_assets)} hourly assets have partitions for {target_partition_key}") return dg.SensorResult( skip_reason=f"All hourly assets have partitions for {target_partition_key}", cursor_result=target_partition_key, metadata={ "partition_checked": target_partition_key, "target_hour": target_datetime.strftime("%Y-%m-%d %H:00"), "total_assets_checked": len(hourly_assets), "missing_assets_count": 0, "check_time": current_utc.isoformat() } ) except Exception as e: logger.error(f"Error checking hourly jobs for {target_partition_key}: {str(e)}") # Send error alert to Slack error_message = _create_error_alert_message(target_partition_key, str(e), current_utc) # Uncomment to enable Slack alerts # _send_slack_alert(error_message, logger) return dg.SensorResult( skip_reason=f"Error checking hourly jobs: {str(e)}", cursor_result=target_partition_key ) def _check_asset_materialization( instance: DagsterInstance, asset_key: AssetKey, asset_name: str, target_partition_key: str, logger ) -> bool: """Check if an asset has materialization for the target partition.""" try: # Try multiple approaches to check for materialization # Method 1: Try fetch_materializations (recommended API) try: if hasattr(instance, 'fetch_materializations'): materializations = instance.fetch_materializations( records_filter=asset_key, limit=100 # Hourly assets may have many recent partitions ) for materialization in materializations.records: if hasattr(materialization, 'partition_key') and materialization.partition_key == target_partition_key: logger.debug(f"Found materialization for {asset_name} partition {target_partition_key}") return True except Exception as e: logger.debug(f"Method 1 (fetch_materializations) failed for {asset_name}: {e}") # Method 2: Try get_event_records with proper filter try: from dagster import EventRecordsFilter, DagsterEventType event_filter = EventRecordsFilter( event_type=DagsterEventType.ASSET_MATERIALIZATION, asset_key=asset_key ) event_records = instance.get_event_records(event_filter, limit=200) for event_record in event_records: event_log_entry = event_record.event_log_entry if (hasattr(event_log_entry, 'dagster_event') and event_log_entry.dagster_event and event_log_entry.dagster_event.is_step_materialization): # Get partition key partition_key = None if hasattr(event_log_entry, 'partition_key'): partition_key = event_log_entry.partition_key elif hasattr(event_log_entry.dagster_event, 'partition_key'): partition_key = event_log_entry.dagster_event.partition_key if partition_key == target_partition_key: logger.debug(f"Found materialization for {asset_name} partition {target_partition_key}") return True except Exception as e: logger.debug(f"Method 2 (get_event_records) failed for {asset_name}: {e}") # Method 3: Check asset records try: asset_record = instance.get_asset_record(asset_key) if asset_record and asset_record.asset_entry.last_materialization: last_mat = asset_record.asset_entry.last_materialization if hasattr(last_mat, 'partition_key') and last_mat.partition_key == target_partition_key: logger.debug(f"Found asset record materialization for {asset_name} partition {target_partition_key}") return True except Exception as e: logger.debug(f"Method 3 (asset_record) failed for {asset_name}: {e}") logger.warning(f"No materialization found for {asset_name} partition {target_partition_key}") return False except Exception as e: logger.error(f"Error checking materialization for {asset_name}: {str(e)}") return False def _create_consolidated_alert_message( missing_assets: List[Dict], partition_key: str, check_time: datetime, target_datetime: datetime ) -> str: """Create consolidated Slack alert message for all missing partitions.""" # Group by status missing_materializations = [a for a in missing_assets if a["status"] == "missing_materialization"] check_errors = [a for a in missing_assets if a["status"] == "check_error"] message = f"""🚨 *Hourly Jobs SLA Alert* 🚨 *Partition Hour:* `{target_datetime.strftime('%Y-%m-%d %H:00')} UTC` *Check Time:* {check_time.strftime('%Y-%m-%d %H:%M:%S')} UTC *SLA Deadline:* {SLA_DEADLINE_HOURS} hours after partition hour *Hours Overdue:* {SLA_DEADLINE_HOURS} *Summary:* {len(missing_assets)} assets missing partitions""" if missing_materializations: message += f""" *Missing Materializations ({len(missing_materializations)}):*""" for asset in missing_materializations[:15]: # Show more for hourly (15 vs 10 for daily) message += f"\n• `{asset['asset_name']}`" if len(missing_materializations) > 15: message += f"\n• ... and {len(missing_materializations) - 15} more" if check_errors: message += f""" *Check Errors ({len(check_errors)}):*""" for asset in check_errors[:5]: message += f"\n• `{asset['asset_name']}`: {asset.get('error', 'Unknown error')}" if len(check_errors) > 5: message += f"\n• ... and {len(check_errors) - 5} more errors" message += f""" *Action Required:* Check Dagster UI for failed jobs and partitions *Runbook:* Investigate assets and backfill missing hourly partitions *Channel:* #{SLACK_CHANNEL}""" return message def _create_error_alert_message(partition_key: str, error: str, check_time: datetime) -> str: """Create formatted Slack alert message for sensor errors.""" return f"""⚠️ *Hourly Jobs Monitor Error* ⚠️ *Partition:* `{partition_key}` *Check Time:* {check_time.strftime('%Y-%m-%d %H:%M:%S')} UTC *Error:* {error} *Status:* ❌ Hourly jobs monitoring failed *Action Required:* Please investigate monitoring sensor *Channel:* #{SLACK_CHANNEL}""" def _send_slack_alert(message: str, logger) -> bool: """Send alert message to Slack channel.""" try: slack_url = "https://slack.com/api/chat.postMessage" headers = { "Authorization": f"Bearer {SLACK_TOKEN}", "Content-Type": "application/json" } payload = { "channel": f"#{SLACK_CHANNEL}", "text": message, "username": "Dagster Hourly Jobs Monitor", "icon_emoji": ":alarm_clock:" } response = requests.post(slack_url, headers=headers, json=payload) response_data = response.json() if response_data.get("ok"): logger.info(f"Slack alert sent successfully to #{SLACK_CHANNEL}") return True else: logger.error(f"Failed to send Slack alert: {response_data.get('error', 'Unknown error')}") return False except Exception as e: logger.error(f"Exception sending Slack alert: {str(e)}") return False