From ce49b9003cb759e82b3d8bed8b0cfc44705c41b4 Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Mon, 24 Mar 2025 13:53:53 +0200 Subject: [PATCH 01/42] WIP --- src/robusta/integrations/slack/sender.py | 455 +++++++++++++++++++---- 1 file changed, 391 insertions(+), 64 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 46fcd4c22..2499c1a2e 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -4,7 +4,7 @@ import tempfile from datetime import datetime, timedelta from itertools import chain -from typing import Any, Dict, List, Set +from typing import Any, Dict, List, Optional, Set import certifi import humanize @@ -214,14 +214,36 @@ def __to_slack(self, block: BaseBlock, sink_name: str) -> List[SlackBlock]: return [] # no reason to crash the entire report def __upload_file_to_slack(self, block: FileBlock, max_log_file_limit_kb: int) -> str: + """Upload a file to slack and return a link to it""" truncated_content = block.truncate_content(max_file_size_bytes=max_log_file_limit_kb * 1000) - """Upload a file to slack and return a link to it""" - with tempfile.NamedTemporaryFile() as f: - f.write(truncated_content) - f.flush() - result = self.slack_client.files_upload_v2(title=block.filename, file=f.name, filename=block.filename) - return result["file"]["permalink"] + try: + with tempfile.NamedTemporaryFile() as f: + f.write(truncated_content) + f.flush() + + # First try files_upload_v2 method (newer API) + try: + result = self.slack_client.files_upload_v2( + title=block.filename, + file=f.name, + filename=block.filename + ) + return result["file"]["permalink"] + except (AttributeError, SlackApiError) as e: + # Fall back to the older files_upload method + logging.info(f"Falling back to files_upload: {e}") + result = self.slack_client.files_upload( + title=block.filename, + file=f.name, + filename=block.filename, + channels=self.slack_channel + ) + return result["file"]["permalink"] + except Exception as e: + logging.error(f"Error uploading file {block.filename} to Slack: {e}") + # Return a descriptive message rather than failing + return f"Error uploading {block.filename} - {str(e)}" def prepare_slack_text(self, message: str, max_log_file_limit_kb: int, files: List[FileBlock] = []): if files: @@ -364,25 +386,132 @@ def __create_holmes_callback(self, finding: Finding) -> CallbackBlock: def __create_finding_header( self, finding: Finding, status: FindingStatus, platform_enabled: bool, include_investigate_link: bool - ) -> MarkdownBlock: + ) -> List[SlackBlock]: title = finding.title.removeprefix("[RESOLVED] ") sev = finding.severity + + # Create the title section similar to JIRA format + if platform_enabled and include_investigate_link: + title_text = f"*<{finding.get_investigate_uri(self.account_id, self.cluster_name)}|{title}>*" + else: + title_text = f"*{title}*" + + title_block = { + "type": "section", + "text": { + "type": "mrkdwn", + "text": title_text + } + } + + # Create metadata line similar to JIRA layout + source_text = f"Source: {self.cluster_name}" + + # Simplify status to just Firing/Resolved + status_text = "Firing" if status == FindingStatus.FIRING else "Resolved" + + # Get type information separately if finding.source == FindingSource.PROMETHEUS: - status_name: str = ( - f"{status.to_emoji()} `Prometheus Alert Firing` {status.to_emoji()}" - if status == FindingStatus.FIRING - else f"{status.to_emoji()} *Prometheus resolved*" - ) + alert_type = "Alert" elif finding.source == FindingSource.KUBERNETES_API_SERVER: - status_name: str = "šŸ‘€ *K8s event detected*" + alert_type = "K8s Event" else: - status_name: str = "šŸ‘€ *Notification*" - if platform_enabled and include_investigate_link: - title = f"<{finding.get_investigate_uri(self.account_id, self.cluster_name)}|*{title}*>" - return MarkdownBlock( - f"""{status_name} {sev.to_emoji()} *{sev.name.capitalize()}* -{title}""" - ) + alert_type = "Notification" + + # Create a context block with metadata similar to JIRA format + context_elements = [ + { + "type": "mrkdwn", + "text": f"{status.to_emoji()} Status: {status_text}" + }, + { + "type": "mrkdwn", + "text": f":bell: Type: {alert_type}" + }, + { + "type": "mrkdwn", + "text": f"{sev.to_emoji()} Severity: {sev.name.capitalize()}" + }, + { + "type": "mrkdwn", + "text": f":globe_with_meridians: {source_text}" + } + ] + + # Add relevant labels if available + if finding.subject and finding.subject.labels: + # Try to find important labels like app, component, etc. + important_labels = ["app", "component", "namespace", "pod", "container"] + for label in important_labels: + if label in finding.subject.labels: + emoji = ":package:" if label == "app" else \ + ":gear:" if label == "component" else \ + ":file_folder:" if label == "namespace" else \ + ":ship:" if label == "pod" else \ + ":desktop_computer:" if label == "container" else ":label:" + + context_elements.append({ + "type": "mrkdwn", + "text": f"{emoji} {label.capitalize()}: {finding.subject.labels[label]}" + }) + # Limit to 5 elements for better display + if len(context_elements) >= 5: + break + + context_block = { + "type": "context", + "elements": context_elements + } + + return [title_block, context_block] + + def __get_enrichment_title(self, enrichment_type: Optional[EnrichmentType]) -> str: + """Get a user-friendly title for an enrichment type""" + if enrichment_type is None: + return "Additional Information" + + titles = { + EnrichmentType.graph: "Performance Graphs", + EnrichmentType.ai_analysis: "AI Analysis", + EnrichmentType.node_info: "Node Information", + EnrichmentType.container_info: "Container Information", + EnrichmentType.k8s_events: "Kubernetes Events", + EnrichmentType.alert_labels: "Alert Labels", + EnrichmentType.diff: "Resource Changes", + EnrichmentType.text_file: "Logs", + EnrichmentType.crash_info: "Crash Information", + EnrichmentType.image_pull_backoff_info: "Image Pull Error", + EnrichmentType.pending_pod_info: "Pod Scheduling Information" + } + + return titles.get(enrichment_type, str(enrichment_type).replace("EnrichmentType.", "").replace("_", " ").title()) + + def __get_enrichment_color(self, enrichment_type: Optional[EnrichmentType], status: FindingStatus) -> str: + """Get a color for an enrichment type""" + if enrichment_type is None: + return "#717274" # Default gray + + # Status colors + if status == FindingStatus.RESOLVED: + status_color = "#00B302" # Green + else: + status_color = "#EF311F" # Red + + colors = { + EnrichmentType.graph: "#1E88E5", # Blue + EnrichmentType.ai_analysis: "#8E24AA", # Purple + EnrichmentType.node_info: "#26A69A", # Teal + EnrichmentType.container_info: "#FFA000", # Amber + EnrichmentType.k8s_events: "#5D4037", # Brown + EnrichmentType.alert_labels: status_color, # Use status color + EnrichmentType.diff: "#00897B", # Teal dark + EnrichmentType.text_file: "#616161", # Gray + EnrichmentType.crash_info: "#D32F2F", # Red + EnrichmentType.image_pull_backoff_info: "#F57C00", # Orange + EnrichmentType.pending_pod_info: "#FFB300" # Amber light + } + + return colors.get(enrichment_type, "#717274") # Default gray if not found def __create_links( self, @@ -426,9 +555,28 @@ def __send_tool_usage(self, parent_thread: str, slack_channel: str, tool_calls: text = "*AI used info from alert and the following tools:*" for tool in tool_calls: - file_response = self.slack_client.files_upload_v2(content=tool.result, title=f"{tool.description}") - permalink = file_response["file"]["permalink"] - text += f"\n• `<{permalink}|{tool.description}>`" + try: + # First try files_upload_v2 method (newer API) + try: + file_response = self.slack_client.files_upload_v2( + content=tool.result, + title=f"{tool.description}" + ) + except (AttributeError, SlackApiError) as e: + # Fall back to the older files_upload method + logging.info(f"Falling back to files_upload: {e}") + file_response = self.slack_client.files_upload( + content=tool.result, + title=f"{tool.description}", + filename=f"{tool.description}.txt", + channels=slack_channel + ) + + permalink = file_response["file"]["permalink"] + text += f"\n• `<{permalink}|{tool.description}>`" + except Exception as e: + logging.error(f"Error uploading tool result to Slack: {e}") + text += f"\n• `{tool.description}` (upload failed: {str(e)})" self.slack_client.chat_postMessage( channel=slack_channel, @@ -516,6 +664,7 @@ def send_finding_to_slack( ) -> str: blocks: List[BaseBlock] = [] attachment_blocks: List[BaseBlock] = [] + direct_slack_blocks: List[SlackBlock] = [] # For JIRA-style blocks we'll add directly slack_channel = ChannelTransformer.template( sink_params.channel_override, @@ -533,55 +682,233 @@ def send_finding_to_slack( status: FindingStatus = ( FindingStatus.RESOLVED if finding.title.startswith("[RESOLVED]") else FindingStatus.FIRING ) + + # Add JIRA-style header blocks directly if finding.title: - blocks.append(self.__create_finding_header(finding, status, platform_enabled, sink_params.investigate_link)) - - links_block: LinksBlock = self.__create_links( - finding, platform_enabled, sink_params.investigate_link, sink_params.prefer_redirect_to_platform - ) - blocks.append(links_block) - - if HOLMES_ENABLED and HOLMES_ASK_SLACK_BUTTON_ENABLED: - blocks.append(self.__create_holmes_callback(finding)) - - blocks.append(MarkdownBlock(text=f"*Source:* `{self.cluster_name}`")) - if finding.description: - if finding.source == FindingSource.PROMETHEUS: - blocks.append(MarkdownBlock(f"{Emojis.Alert.value} *Alert:* {finding.description}")) - elif finding.source == FindingSource.KUBERNETES_API_SERVER: - blocks.append( - MarkdownBlock(f"{Emojis.K8Notification.value} *K8s event detected:* {finding.description}") - ) - else: - blocks.append(MarkdownBlock(f"{Emojis.K8Notification.value} *Notification:* {finding.description}")) - + direct_slack_blocks.extend(self.__create_finding_header(finding, status, platform_enabled, sink_params.investigate_link)) + + # Create action buttons section similar to JIRA + action_buttons = [] + + # Add investigate button if applicable + if platform_enabled and sink_params.investigate_link: + action_buttons.append({ + "type": "button", + "text": { + "type": "plain_text", + "text": "Investigate šŸ”Ž", + "emoji": True + }, + "url": finding.get_investigate_uri(self.account_id, self.cluster_name) + }) + + # Add silences button if applicable + if finding.add_silence_url and platform_enabled: + action_buttons.append({ + "type": "button", + "text": { + "type": "plain_text", + "text": "Configure Silences šŸ”•", + "emoji": True + }, + "url": finding.get_prometheus_silence_url(self.account_id, self.cluster_name) + }) + + # Add custom links from the finding + for link in finding.links: + link_url = link.url + if link.type == LinkType.PROMETHEUS_GENERATOR_URL and sink_params.prefer_redirect_to_platform and platform_enabled: + link_url = convert_prom_graph_url_to_robusta_metrics_explorer(link.url, self.cluster_name, self.account_id) + + action_buttons.append({ + "type": "button", + "text": { + "type": "plain_text", + "text": link.name, + "emoji": True + }, + "url": link_url + }) + + # Add the action buttons if we have any + if action_buttons: + direct_slack_blocks.append({ + "type": "actions", + "elements": action_buttons[:5] # Slack has a limit of 5 buttons per action block + }) + + # Remove this section as we're handling Holmes callback in a different location now + + # Add a divider to separate header from content + direct_slack_blocks.append({"type": "divider"}) + + # Process enrichments and other blocks unfurl = True + + # Organize enrichments by type + enrichments_by_type = {} + for enrichment in finding.enrichments: if enrichment.annotations.get(EnrichmentAnnotation.SCAN, False): enrichment.blocks = [Transformer.scanReportBlock_to_fileblock(b) for b in enrichment.blocks] # if one of the enrichment specified unfurl=False, this slack message will contain unfurl=False unfurl = bool(unfurl and enrichment.annotations.get(SlackAnnotations.UNFURL, True)) - if enrichment.annotations.get(SlackAnnotations.ATTACHMENT): - attachment_blocks.extend(enrichment.blocks) + + # Group enrichments by type for better organization + enrichment_type = enrichment.enrichment_type + title = enrichment.title + + key = f"{enrichment_type}_{title}" if title else str(enrichment_type) + + if key not in enrichments_by_type: + enrichments_by_type[key] = { + "title": title or self.__get_enrichment_title(enrichment_type), + "blocks": [], + "color": self.__get_enrichment_color(enrichment_type, status), + "type": enrichment_type + } + + enrichments_by_type[key]["blocks"].extend(enrichment.blocks) + + # Let's use the original method for file handling to ensure it works correctly + # First get all file blocks including SVGs and ones from attachments + file_blocks = [] + + # Collect file blocks from all enrichments + for enrichment_key, enrichment_data in enrichments_by_type.items(): + enrichment_blocks = enrichment_data["blocks"] + file_blocks_in_enrichment = [b for b in enrichment_blocks if isinstance(b, FileBlock)] + file_blocks.extend(file_blocks_in_enrichment) + # Remove file blocks from the enrichment as they'll be handled separately + enrichment_data["blocks"] = [b for b in enrichment_blocks if not isinstance(b, FileBlock)] + + # Process SVG files + file_blocks = add_pngs_for_all_svgs(file_blocks) + if not sink_params.send_svg: + file_blocks = [b for b in file_blocks if not b.filename.endswith(".svg")] + + # Convert wide tables to file blocks + table_file_blocks = [] + for enrichment_key, enrichment_data in enrichments_by_type.items(): + enrichment_blocks = enrichment_data["blocks"] + table_blocks = Transformer.tableblock_to_fileblocks(enrichment_blocks, SLACK_TABLE_COLUMNS_LIMIT) + if table_blocks: + file_blocks.extend(table_blocks) + # Remove tables that have been converted to files + enrichment_data["blocks"] = [b for b in enrichment_blocks if not isinstance(b, TableBlock) or + (isinstance(b, TableBlock) and len(b.headers) <= SLACK_TABLE_COLUMNS_LIMIT)] + + # Upload files if needed + message = finding.title # Default fallback message + if file_blocks: + logging.info(f"Uploading {len(file_blocks)} file blocks to Slack") + uploaded_files = [] + for file_block in file_blocks: + # Skip empty files + if file_block.contents is None or len(file_block.contents) == 0: + logging.warning(f"Skipping upload of empty file: {file_block.filename}") + continue + + # The __upload_file_to_slack method now handles errors internally + permalink = self.__upload_file_to_slack(file_block, max_log_file_limit_kb=sink_params.max_log_file_limit_kb) + if "Error uploading" in permalink: + # Error already logged in the upload method + uploaded_files.append(f"* {file_block.filename} - Upload failed") + else: + uploaded_files.append(f"* <{permalink} | {file_block.filename}>") + logging.info(f"Successfully uploaded file {file_block.filename} to Slack") + + if uploaded_files: + # Add file references as their own attachment + if "files" not in enrichments_by_type: + enrichments_by_type["files"] = { + "title": "Files and Attachments", + "blocks": [MarkdownBlock("\n".join(uploaded_files))], + "color": "#717274", # Neutral gray color + "type": "files" + } + else: + enrichments_by_type["files"]["blocks"].append(MarkdownBlock("\n".join(uploaded_files))) + + # Handle Holmes callback blocks + if HOLMES_ENABLED and HOLMES_ASK_SLACK_BUTTON_ENABLED: + callback_block = self.__create_holmes_callback(finding) + direct_slack_blocks.extend(self.__get_action_block_for_choices(sink_params.name, callback_block.choices)) + + # We've removed the footer "Generated by Robusta" as requested + + # Create attachments from grouped enrichments + slack_attachments = [] + + # First add a description attachment if it exists + if finding.description: + slack_attachments.append({ + "color": status.to_color_hex(), + "blocks": [{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": finding.description + } + }] + }) + + # Then add all other enrichment attachments + for enrichment_key, enrichment_data in enrichments_by_type.items(): + slack_blocks = [] + for block in enrichment_data["blocks"]: + if isinstance(block, CallbackBlock): + # Special handling for callback blocks + slack_blocks.extend(self.__get_action_block_for_choices(sink_params.name, block.choices)) + else: + slack_blocks.extend(self.__to_slack(block, sink_params.name)) + + if slack_blocks: + # Create an attachment header + if enrichment_data["title"]: + header_block = { + "type": "header", + "text": { + "type": "plain_text", + "text": enrichment_data["title"], + "emoji": True + } + } + slack_blocks.insert(0, header_block) + + slack_attachments.append({ + "color": enrichment_data["color"], + "blocks": slack_blocks + }) + + try: + if thread_ts: + kwargs = {"thread_ts": thread_ts} else: - blocks.extend(enrichment.blocks) - - blocks.append(DividerBlock()) - - if len(attachment_blocks): - attachment_blocks.append(DividerBlock()) - - return self.__send_blocks_to_slack( - blocks, - attachment_blocks, - finding.title, - sink_params, - unfurl, - status, - slack_channel, - thread_ts=thread_ts, - ) + kwargs = {} + + # Send the message directly with our crafted blocks + resp = self.slack_client.chat_postMessage( + channel=slack_channel, + text=finding.title, # Fallback text + blocks=direct_slack_blocks, + display_as_bot=True, + attachments=slack_attachments if slack_attachments else None, + unfurl_links=unfurl, + unfurl_media=unfurl, + **kwargs, + ) + + # We will need channel ids for future message updates + self.channel_name_to_id[slack_channel] = resp["channel"] + return resp["ts"] + + except Exception as e: + logging.error( + f"error sending message to slack\ne={e}\ntext={finding.title}\nchannel={slack_channel}\nblocks={direct_slack_blocks}" + ) + return "" def send_or_update_summary_message( self, From 96a69e28aaa4341b3fd325bf35dbdf557e716061 Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Thu, 27 Mar 2025 02:45:19 +0200 Subject: [PATCH 02/42] Update sender.py --- src/robusta/integrations/slack/sender.py | 105 ++++++++++++++++++----- 1 file changed, 83 insertions(+), 22 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 2499c1a2e..a644170c6 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -404,8 +404,8 @@ def __create_finding_header( } } - # Create metadata line similar to JIRA layout - source_text = f"Source: {self.cluster_name}" + # Create metadata line with "cluster" instead of "source" + cluster_text = f"Cluster: {self.cluster_name}" # Simplify status to just Firing/Resolved status_text = "Firing" if status == FindingStatus.FIRING else "Resolved" @@ -434,20 +434,50 @@ def __create_finding_header( }, { "type": "mrkdwn", - "text": f":globe_with_meridians: {source_text}" + "text": f":globe_with_meridians: {cluster_text}" } ] - # Add relevant labels if available - if finding.subject and finding.subject.labels: - # Try to find important labels like app, component, etc. - important_labels = ["app", "component", "namespace", "pod", "container"] + # Always show resource kind, namespace and name if available + if finding.subject: + # Add subject kind, namespace and name as a single piece of information + kind_emoji = ":package:" + subject_kind = finding.subject.subject_type.value + subject_namespace = finding.subject.namespace + subject_name = finding.subject.name + + if subject_kind and subject_name: + # Choose emoji based on kind + if subject_kind.lower() == "pod": + kind_emoji = ":ship:" + elif subject_kind.lower() == "deployment": + kind_emoji = ":package:" + elif subject_kind.lower() == "node": + kind_emoji = ":computer:" + elif subject_kind.lower() == "service": + kind_emoji = ":link:" + elif subject_kind.lower() == "job": + kind_emoji = ":clock1:" + elif subject_kind.lower() == "statefulset": + kind_emoji = ":chains:" + + # Format as Kind/Namespace/Name + if subject_namespace: + subject_text = f"{kind_emoji} Resource: {subject_kind}/{subject_namespace}/{subject_name}" + else: + subject_text = f"{kind_emoji} Resource: {subject_kind}/{subject_name}" + + context_elements.append({ + "type": "mrkdwn", + "text": subject_text + }) + + # Add additional useful labels + important_labels = ["app", "component", "container"] for label in important_labels: if label in finding.subject.labels: emoji = ":package:" if label == "app" else \ ":gear:" if label == "component" else \ - ":file_folder:" if label == "namespace" else \ - ":ship:" if label == "pod" else \ ":desktop_computer:" if label == "container" else ":label:" context_elements.append({ @@ -820,16 +850,47 @@ def send_finding_to_slack( logging.info(f"Successfully uploaded file {file_block.filename} to Slack") if uploaded_files: - # Add file references as their own attachment - if "files" not in enrichments_by_type: - enrichments_by_type["files"] = { - "title": "Files and Attachments", - "blocks": [MarkdownBlock("\n".join(uploaded_files))], - "color": "#717274", # Neutral gray color - "type": "files" - } - else: - enrichments_by_type["files"]["blocks"].append(MarkdownBlock("\n".join(uploaded_files))) + # Create a more visually clear display for files + if uploaded_files: + file_section_blocks = [] + + # Add a header for the files section + file_section_blocks.append({ + "type": "header", + "text": { + "type": "plain_text", + "text": "šŸ“ Log Files & Attachments", + "emoji": True + } + }) + + # Create separate section blocks for each file with better formatting + for file_link in uploaded_files: + # Extract filename and link from the markdown format "* " + parts = file_link.split("|") + if len(parts) == 2: + link = parts[0].replace("* <", "") + filename = parts[1].replace(">", "") + + file_section_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{filename}*\nClick to view file contents" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "View File", + "emoji": True + }, + "url": link, + } + }) + + # Add file blocks directly to the main message + direct_slack_blocks.extend(file_section_blocks) # Handle Holmes callback blocks if HOLMES_ENABLED and HOLMES_ASK_SLACK_BUTTON_ENABLED: @@ -948,7 +1009,7 @@ def send_or_update_summary_message( MarkdownBlock(f"*Alerts Summary - {n_total_alerts} Notifications*"), ] - source_txt = f"*Source:* `{self.cluster_name}`" + cluster_txt = f"*Cluster:* `{self.cluster_name}`" if platform_enabled: blocks.extend( [ @@ -956,7 +1017,7 @@ def send_or_update_summary_message( "type": "section", "text": { "type": "mrkdwn", - "text": source_txt, + "text": cluster_txt, }, } ] @@ -971,7 +1032,7 @@ def send_or_update_summary_message( "url": investigate_uri, } else: - blocks.append(MarkdownBlock(text=source_txt)) + blocks.append(MarkdownBlock(text=cluster_txt)) blocks.extend( [ From 0f3710f8da207266a3e0e91ef689e65b0e876e3e Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Thu, 27 Mar 2025 02:49:45 +0200 Subject: [PATCH 03/42] Update sender.py --- src/robusta/integrations/slack/sender.py | 75 ++++++++++-------------- 1 file changed, 31 insertions(+), 44 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index a644170c6..6fd7547bd 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -517,31 +517,9 @@ def __get_enrichment_title(self, enrichment_type: Optional[EnrichmentType]) -> s return titles.get(enrichment_type, str(enrichment_type).replace("EnrichmentType.", "").replace("_", " ").title()) def __get_enrichment_color(self, enrichment_type: Optional[EnrichmentType], status: FindingStatus) -> str: - """Get a color for an enrichment type""" - if enrichment_type is None: - return "#717274" # Default gray - - # Status colors - if status == FindingStatus.RESOLVED: - status_color = "#00B302" # Green - else: - status_color = "#EF311F" # Red - - colors = { - EnrichmentType.graph: "#1E88E5", # Blue - EnrichmentType.ai_analysis: "#8E24AA", # Purple - EnrichmentType.node_info: "#26A69A", # Teal - EnrichmentType.container_info: "#FFA000", # Amber - EnrichmentType.k8s_events: "#5D4037", # Brown - EnrichmentType.alert_labels: status_color, # Use status color - EnrichmentType.diff: "#00897B", # Teal dark - EnrichmentType.text_file: "#616161", # Gray - EnrichmentType.crash_info: "#D32F2F", # Red - EnrichmentType.image_pull_backoff_info: "#F57C00", # Orange - EnrichmentType.pending_pod_info: "#FFB300" # Amber light - } - - return colors.get(enrichment_type, "#717274") # Default gray if not found + """Get a color based on status""" + # We're now using a single color for all attachments based on status + return status.to_color_hex() def __create_links( self, @@ -795,7 +773,6 @@ def send_finding_to_slack( enrichments_by_type[key] = { "title": title or self.__get_enrichment_title(enrichment_type), "blocks": [], - "color": self.__get_enrichment_color(enrichment_type, status), "type": enrichment_type } @@ -899,23 +876,23 @@ def send_finding_to_slack( # We've removed the footer "Generated by Robusta" as requested - # Create attachments from grouped enrichments - slack_attachments = [] + # Create a single attachment with all enrichment blocks + all_attachment_blocks = [] - # First add a description attachment if it exists + # First add description if it exists if finding.description: - slack_attachments.append({ - "color": status.to_color_hex(), - "blocks": [{ - "type": "section", - "text": { - "type": "mrkdwn", - "text": finding.description - } - }] + all_attachment_blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": finding.description + } }) - # Then add all other enrichment attachments + # Add a divider after the description + all_attachment_blocks.append({"type": "divider"}) + + # Then add all other enrichment blocks for enrichment_key, enrichment_data in enrichments_by_type.items(): slack_blocks = [] for block in enrichment_data["blocks"]: @@ -926,7 +903,7 @@ def send_finding_to_slack( slack_blocks.extend(self.__to_slack(block, sink_params.name)) if slack_blocks: - # Create an attachment header + # Add a header for each enrichment type if enrichment_data["title"]: header_block = { "type": "header", @@ -938,10 +915,20 @@ def send_finding_to_slack( } slack_blocks.insert(0, header_block) - slack_attachments.append({ - "color": enrichment_data["color"], - "blocks": slack_blocks - }) + # Add the blocks to the main attachment + all_attachment_blocks.extend(slack_blocks) + + # Add a divider between different enrichment types (except after the last one) + if enrichment_key != list(enrichments_by_type.keys())[-1]: + all_attachment_blocks.append({"type": "divider"}) + + # Create a single attachment with all blocks + slack_attachments = [] + if all_attachment_blocks: + slack_attachments = [{ + "color": status.to_color_hex(), + "blocks": all_attachment_blocks + }] try: if thread_ts: From cdec3f715eed4505dfbac2eeca91f339b751af54 Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Thu, 27 Mar 2025 02:56:57 +0200 Subject: [PATCH 04/42] Update sender.py --- src/robusta/integrations/slack/sender.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 6fd7547bd..8e7ce9ea1 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -747,8 +747,8 @@ def send_finding_to_slack( # Remove this section as we're handling Holmes callback in a different location now - # Add a divider to separate header from content - direct_slack_blocks.append({"type": "divider"}) + # No divider between header and content + pass # Process enrichments and other blocks unfurl = True @@ -889,8 +889,8 @@ def send_finding_to_slack( } }) - # Add a divider after the description - all_attachment_blocks.append({"type": "divider"}) + # No divider after description + pass # Then add all other enrichment blocks for enrichment_key, enrichment_data in enrichments_by_type.items(): @@ -918,13 +918,14 @@ def send_finding_to_slack( # Add the blocks to the main attachment all_attachment_blocks.extend(slack_blocks) - # Add a divider between different enrichment types (except after the last one) - if enrichment_key != list(enrichments_by_type.keys())[-1]: - all_attachment_blocks.append({"type": "divider"}) + # No divider between enrichment sections + pass # Create a single attachment with all blocks slack_attachments = [] if all_attachment_blocks: + # No bottom padding needed + slack_attachments = [{ "color": status.to_color_hex(), "blocks": all_attachment_blocks From 7ff23d6b11980ae50f8e97b0fe5d1b0dd4f31f3e Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Thu, 27 Mar 2025 03:02:19 +0200 Subject: [PATCH 05/42] Update sender.py --- src/robusta/integrations/slack/sender.py | 48 ++++++------------------ 1 file changed, 11 insertions(+), 37 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 8e7ce9ea1..7b5195175 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -827,47 +827,21 @@ def send_finding_to_slack( logging.info(f"Successfully uploaded file {file_block.filename} to Slack") if uploaded_files: - # Create a more visually clear display for files + # Add files to the enrichments to be rendered in the attachment if uploaded_files: - file_section_blocks = [] + # Create a key for files + file_key = "log_files" - # Add a header for the files section - file_section_blocks.append({ - "type": "header", - "text": { - "type": "plain_text", - "text": "šŸ“ Log Files & Attachments", - "emoji": True - } - }) - - # Create separate section blocks for each file with better formatting + # Create simple markdown links for files, allowing slack to unfurl + file_links_markdown = [] for file_link in uploaded_files: - # Extract filename and link from the markdown format "* " - parts = file_link.split("|") - if len(parts) == 2: - link = parts[0].replace("* <", "") - filename = parts[1].replace(">", "") - - file_section_blocks.append({ - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"*{filename}*\nClick to view file contents" - }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "View File", - "emoji": True - }, - "url": link, - } - }) + file_links_markdown.append(file_link) # Already in format "* " - # Add file blocks directly to the main message - direct_slack_blocks.extend(file_section_blocks) + enrichments_by_type[file_key] = { + "title": "Log Files & Attachments", + "blocks": [MarkdownBlock("\n".join(file_links_markdown))], + "type": EnrichmentType.text_file + } # Handle Holmes callback blocks if HOLMES_ENABLED and HOLMES_ASK_SLACK_BUTTON_ENABLED: From 26ed319dd7314385b9313e3dab78db9646fe1702 Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Thu, 27 Mar 2025 03:13:14 +0200 Subject: [PATCH 06/42] Update sender.py --- src/robusta/integrations/slack/sender.py | 290 ++++++----------------- 1 file changed, 71 insertions(+), 219 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 7b5195175..558c675b7 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -312,6 +312,7 @@ def __send_blocks_to_slack( kwargs = {"thread_ts": thread_ts} else: kwargs = {} + # Create a single attachment with a consistent color, containing all enrichment blocks resp = self.slack_client.chat_postMessage( channel=channel, text=message, @@ -471,23 +472,7 @@ def __create_finding_header( "type": "mrkdwn", "text": subject_text }) - - # Add additional useful labels - important_labels = ["app", "component", "container"] - for label in important_labels: - if label in finding.subject.labels: - emoji = ":package:" if label == "app" else \ - ":gear:" if label == "component" else \ - ":desktop_computer:" if label == "container" else ":label:" - - context_elements.append({ - "type": "mrkdwn", - "text": f"{emoji} {label.capitalize()}: {finding.subject.labels[label]}" - }) - # Limit to 5 elements for better display - if len(context_elements) >= 5: - break - + context_block = { "type": "context", "elements": context_elements @@ -672,7 +657,7 @@ def send_finding_to_slack( ) -> str: blocks: List[BaseBlock] = [] attachment_blocks: List[BaseBlock] = [] - direct_slack_blocks: List[SlackBlock] = [] # For JIRA-style blocks we'll add directly + header_blocks: List[SlackBlock] = [] # JIRA-style header blocks slack_channel = ChannelTransformer.template( sink_params.channel_override, @@ -691,71 +676,34 @@ def send_finding_to_slack( FindingStatus.RESOLVED if finding.title.startswith("[RESOLVED]") else FindingStatus.FIRING ) - # Add JIRA-style header blocks directly + # Get JIRA-style header blocks if finding.title: - direct_slack_blocks.extend(self.__create_finding_header(finding, status, platform_enabled, sink_params.investigate_link)) - - # Create action buttons section similar to JIRA - action_buttons = [] - - # Add investigate button if applicable - if platform_enabled and sink_params.investigate_link: - action_buttons.append({ - "type": "button", - "text": { - "type": "plain_text", - "text": "Investigate šŸ”Ž", - "emoji": True - }, - "url": finding.get_investigate_uri(self.account_id, self.cluster_name) - }) - - # Add silences button if applicable - if finding.add_silence_url and platform_enabled: - action_buttons.append({ - "type": "button", - "text": { - "type": "plain_text", - "text": "Configure Silences šŸ”•", - "emoji": True - }, - "url": finding.get_prometheus_silence_url(self.account_id, self.cluster_name) - }) - - # Add custom links from the finding - for link in finding.links: - link_url = link.url - if link.type == LinkType.PROMETHEUS_GENERATOR_URL and sink_params.prefer_redirect_to_platform and platform_enabled: - link_url = convert_prom_graph_url_to_robusta_metrics_explorer(link.url, self.cluster_name, self.account_id) - - action_buttons.append({ - "type": "button", - "text": { - "type": "plain_text", - "text": link.name, - "emoji": True - }, - "url": link_url - }) - - # Add the action buttons if we have any - if action_buttons: - direct_slack_blocks.append({ - "type": "actions", - "elements": action_buttons[:5] # Slack has a limit of 5 buttons per action block - }) + header_blocks = self.__create_finding_header(finding, status, platform_enabled, sink_params.investigate_link) + + links_block: LinksBlock = self.__create_links( + finding, platform_enabled, sink_params.investigate_link, sink_params.prefer_redirect_to_platform + ) + blocks.append(links_block) + + if HOLMES_ENABLED and HOLMES_ASK_SLACK_BUTTON_ENABLED: + blocks.append(self.__create_holmes_callback(finding)) + + # Description handling + if finding.description: + # We'll put the description in the attachment rather than the main message + description_text = finding.description + if finding.source == FindingSource.PROMETHEUS: + description_text = f"{Emojis.Alert.value} *Alert:* {description_text}" + elif finding.source == FindingSource.KUBERNETES_API_SERVER: + description_text = f"{Emojis.K8Notification.value} *K8s event detected:* {description_text}" + else: + description_text = f"{Emojis.K8Notification.value} *Notification:* {description_text}" - # Remove this section as we're handling Holmes callback in a different location now + attachment_blocks.append(MarkdownBlock(description_text)) - # No divider between header and content - pass - - # Process enrichments and other blocks unfurl = True - - # Organize enrichments by type - enrichments_by_type = {} - + all_file_blocks = [] # Collect all file blocks to be handled specially + for enrichment in finding.enrichments: if enrichment.annotations.get(EnrichmentAnnotation.SCAN, False): enrichment.blocks = [Transformer.scanReportBlock_to_fileblock(b) for b in enrichment.blocks] @@ -763,173 +711,77 @@ def send_finding_to_slack( # if one of the enrichment specified unfurl=False, this slack message will contain unfurl=False unfurl = bool(unfurl and enrichment.annotations.get(SlackAnnotations.UNFURL, True)) - # Group enrichments by type for better organization - enrichment_type = enrichment.enrichment_type - title = enrichment.title + # Separate file blocks from normal blocks + file_blocks_in_enrichment = [b for b in enrichment.blocks if isinstance(b, FileBlock)] + non_file_blocks = [b for b in enrichment.blocks if not isinstance(b, FileBlock)] - key = f"{enrichment_type}_{title}" if title else str(enrichment_type) - - if key not in enrichments_by_type: - enrichments_by_type[key] = { - "title": title or self.__get_enrichment_title(enrichment_type), - "blocks": [], - "type": enrichment_type - } + # Collect file blocks + all_file_blocks.extend(file_blocks_in_enrichment) - enrichments_by_type[key]["blocks"].extend(enrichment.blocks) - - # Let's use the original method for file handling to ensure it works correctly - # First get all file blocks including SVGs and ones from attachments - file_blocks = [] + # Put non-file blocks in the attachment + attachment_blocks.extend(non_file_blocks) - # Collect file blocks from all enrichments - for enrichment_key, enrichment_data in enrichments_by_type.items(): - enrichment_blocks = enrichment_data["blocks"] - file_blocks_in_enrichment = [b for b in enrichment_blocks if isinstance(b, FileBlock)] - file_blocks.extend(file_blocks_in_enrichment) - # Remove file blocks from the enrichment as they'll be handled separately - enrichment_data["blocks"] = [b for b in enrichment_blocks if not isinstance(b, FileBlock)] - - # Process SVG files - file_blocks = add_pngs_for_all_svgs(file_blocks) + # Add file blocks to the main blocks for proper handling + blocks.extend(all_file_blocks) + + if len(attachment_blocks): + attachment_blocks.append(DividerBlock()) + + # We need to create a minimal version of __send_blocks_to_slack + # that can handle both our header blocks and regular blocks + file_blocks = add_pngs_for_all_svgs([b for b in blocks if isinstance(b, FileBlock)]) if not sink_params.send_svg: file_blocks = [b for b in file_blocks if not b.filename.endswith(".svg")] - - # Convert wide tables to file blocks - table_file_blocks = [] - for enrichment_key, enrichment_data in enrichments_by_type.items(): - enrichment_blocks = enrichment_data["blocks"] - table_blocks = Transformer.tableblock_to_fileblocks(enrichment_blocks, SLACK_TABLE_COLUMNS_LIMIT) - if table_blocks: - file_blocks.extend(table_blocks) - # Remove tables that have been converted to files - enrichment_data["blocks"] = [b for b in enrichment_blocks if not isinstance(b, TableBlock) or - (isinstance(b, TableBlock) and len(b.headers) <= SLACK_TABLE_COLUMNS_LIMIT)] - - # Upload files if needed - message = finding.title # Default fallback message - if file_blocks: - logging.info(f"Uploading {len(file_blocks)} file blocks to Slack") - uploaded_files = [] - for file_block in file_blocks: - # Skip empty files - if file_block.contents is None or len(file_block.contents) == 0: - logging.warning(f"Skipping upload of empty file: {file_block.filename}") - continue - - # The __upload_file_to_slack method now handles errors internally - permalink = self.__upload_file_to_slack(file_block, max_log_file_limit_kb=sink_params.max_log_file_limit_kb) - if "Error uploading" in permalink: - # Error already logged in the upload method - uploaded_files.append(f"* {file_block.filename} - Upload failed") - else: - uploaded_files.append(f"* <{permalink} | {file_block.filename}>") - logging.info(f"Successfully uploaded file {file_block.filename} to Slack") - - if uploaded_files: - # Add files to the enrichments to be rendered in the attachment - if uploaded_files: - # Create a key for files - file_key = "log_files" - - # Create simple markdown links for files, allowing slack to unfurl - file_links_markdown = [] - for file_link in uploaded_files: - file_links_markdown.append(file_link) # Already in format "* " - - enrichments_by_type[file_key] = { - "title": "Log Files & Attachments", - "blocks": [MarkdownBlock("\n".join(file_links_markdown))], - "type": EnrichmentType.text_file - } - # Handle Holmes callback blocks - if HOLMES_ENABLED and HOLMES_ASK_SLACK_BUTTON_ENABLED: - callback_block = self.__create_holmes_callback(finding) - direct_slack_blocks.extend(self.__get_action_block_for_choices(sink_params.name, callback_block.choices)) - - # We've removed the footer "Generated by Robusta" as requested - - # Create a single attachment with all enrichment blocks - all_attachment_blocks = [] + other_blocks = [b for b in blocks if not isinstance(b, FileBlock)] + + # wide tables aren't displayed properly on slack. looks better in a text file + file_blocks.extend(Transformer.tableblock_to_fileblocks(other_blocks, SLACK_TABLE_COLUMNS_LIMIT)) + file_blocks.extend(Transformer.tableblock_to_fileblocks(attachment_blocks, SLACK_TABLE_COLUMNS_LIMIT)) + + message = self.prepare_slack_text( + finding.title, max_log_file_limit_kb=sink_params.max_log_file_limit_kb, files=file_blocks + ) - # First add description if it exists - if finding.description: - all_attachment_blocks.append({ - "type": "section", - "text": { - "type": "mrkdwn", - "text": finding.description - } - }) - - # No divider after description - pass - - # Then add all other enrichment blocks - for enrichment_key, enrichment_data in enrichments_by_type.items(): - slack_blocks = [] - for block in enrichment_data["blocks"]: - if isinstance(block, CallbackBlock): - # Special handling for callback blocks - slack_blocks.extend(self.__get_action_block_for_choices(sink_params.name, block.choices)) - else: - slack_blocks.extend(self.__to_slack(block, sink_params.name)) - - if slack_blocks: - # Add a header for each enrichment type - if enrichment_data["title"]: - header_block = { - "type": "header", - "text": { - "type": "plain_text", - "text": enrichment_data["title"], - "emoji": True - } - } - slack_blocks.insert(0, header_block) - - # Add the blocks to the main attachment - all_attachment_blocks.extend(slack_blocks) - - # No divider between enrichment sections - pass - - # Create a single attachment with all blocks - slack_attachments = [] - if all_attachment_blocks: - # No bottom padding needed - - slack_attachments = [{ - "color": status.to_color_hex(), - "blocks": all_attachment_blocks - }] + # Convert BaseBlocks to Slack blocks + output_blocks = [] + for block in other_blocks: + output_blocks.extend(self.__to_slack(block, sink_params.name)) + attachment_slack_blocks = [] + for block in attachment_blocks: + attachment_slack_blocks.extend(self.__to_slack(block, sink_params.name)) + + # Combine with header blocks + all_blocks = header_blocks + output_blocks + try: if thread_ts: kwargs = {"thread_ts": thread_ts} else: kwargs = {} - # Send the message directly with our crafted blocks + # Send the message with our JIRA-style headers and single color attachment resp = self.slack_client.chat_postMessage( channel=slack_channel, - text=finding.title, # Fallback text - blocks=direct_slack_blocks, + text=message, + blocks=all_blocks, display_as_bot=True, - attachments=slack_attachments if slack_attachments else None, + attachments=( + [{"color": status.to_color_hex(), "blocks": attachment_slack_blocks}] if attachment_slack_blocks else None + ), unfurl_links=unfurl, unfurl_media=unfurl, **kwargs, ) - # We will need channel ids for future message updates + # Store channel id for future use self.channel_name_to_id[slack_channel] = resp["channel"] return resp["ts"] except Exception as e: logging.error( - f"error sending message to slack\ne={e}\ntext={finding.title}\nchannel={slack_channel}\nblocks={direct_slack_blocks}" + f"error sending message to slack\ne={e}\ntext={message}\nchannel={slack_channel}" ) return "" From ef3214d8a81750edf80f45a41df4126b479f63d3 Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Thu, 27 Mar 2025 03:20:34 +0200 Subject: [PATCH 07/42] Update sender.py --- src/robusta/integrations/slack/sender.py | 45 +++++++----------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 558c675b7..c72740c4c 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -222,24 +222,13 @@ def __upload_file_to_slack(self, block: FileBlock, max_log_file_limit_kb: int) - f.write(truncated_content) f.flush() - # First try files_upload_v2 method (newer API) - try: - result = self.slack_client.files_upload_v2( - title=block.filename, - file=f.name, - filename=block.filename - ) - return result["file"]["permalink"] - except (AttributeError, SlackApiError) as e: - # Fall back to the older files_upload method - logging.info(f"Falling back to files_upload: {e}") - result = self.slack_client.files_upload( - title=block.filename, - file=f.name, - filename=block.filename, - channels=self.slack_channel - ) - return result["file"]["permalink"] + # Use files_upload_v2 method (newer API) + result = self.slack_client.files_upload_v2( + title=block.filename, + file=f.name, + filename=block.filename + ) + return result["file"]["permalink"] except Exception as e: logging.error(f"Error uploading file {block.filename} to Slack: {e}") # Return a descriptive message rather than failing @@ -549,21 +538,11 @@ def __send_tool_usage(self, parent_thread: str, slack_channel: str, tool_calls: text = "*AI used info from alert and the following tools:*" for tool in tool_calls: try: - # First try files_upload_v2 method (newer API) - try: - file_response = self.slack_client.files_upload_v2( - content=tool.result, - title=f"{tool.description}" - ) - except (AttributeError, SlackApiError) as e: - # Fall back to the older files_upload method - logging.info(f"Falling back to files_upload: {e}") - file_response = self.slack_client.files_upload( - content=tool.result, - title=f"{tool.description}", - filename=f"{tool.description}.txt", - channels=slack_channel - ) + # Use files_upload_v2 method + file_response = self.slack_client.files_upload_v2( + content=tool.result, + title=f"{tool.description}" + ) permalink = file_response["file"]["permalink"] text += f"\n• `<{permalink}|{tool.description}>`" From d79a00d4072193ce737cfffd974ad5f23fb3ce49 Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Thu, 27 Mar 2025 06:01:51 +0200 Subject: [PATCH 08/42] Update sender.py --- src/robusta/integrations/slack/sender.py | 33 ++++++++++++++---------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index c72740c4c..adf594940 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -671,12 +671,7 @@ def send_finding_to_slack( if finding.description: # We'll put the description in the attachment rather than the main message description_text = finding.description - if finding.source == FindingSource.PROMETHEUS: - description_text = f"{Emojis.Alert.value} *Alert:* {description_text}" - elif finding.source == FindingSource.KUBERNETES_API_SERVER: - description_text = f"{Emojis.K8Notification.value} *K8s event detected:* {description_text}" - else: - description_text = f"{Emojis.K8Notification.value} *Notification:* {description_text}" + # No prefixes, just the description itself attachment_blocks.append(MarkdownBlock(description_text)) @@ -702,9 +697,8 @@ def send_finding_to_slack( # Add file blocks to the main blocks for proper handling blocks.extend(all_file_blocks) - - if len(attachment_blocks): - attachment_blocks.append(DividerBlock()) + + # No divider in the main blocks # We need to create a minimal version of __send_blocks_to_slack # that can handle both our header blocks and regular blocks @@ -740,15 +734,28 @@ def send_finding_to_slack( else: kwargs = {} - # Send the message with our JIRA-style headers and single color attachment + # Create a single attachment with all blocks and a divider at the end + attachments = [] + + # Add divider to the end of attachment blocks if there are any + all_attachment_blocks = attachment_slack_blocks.copy() if attachment_slack_blocks else [] + + # Always add a divider at the end + all_attachment_blocks.append({"type": "divider"}) + + # Create a single attachment with the status color + attachments = [{ + "color": status.to_color_hex(), + "blocks": all_attachment_blocks + }] + + # Send the message with our JIRA-style headers and attachments resp = self.slack_client.chat_postMessage( channel=slack_channel, text=message, blocks=all_blocks, display_as_bot=True, - attachments=( - [{"color": status.to_color_hex(), "blocks": attachment_slack_blocks}] if attachment_slack_blocks else None - ), + attachments=attachments, unfurl_links=unfurl, unfurl_media=unfurl, **kwargs, From 64bafc53453c5396084796d8e36edfa353fa23fd Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Thu, 27 Mar 2025 06:21:29 +0200 Subject: [PATCH 09/42] Update sender.py --- src/robusta/integrations/slack/sender.py | 38 ++++++------------------ 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index adf594940..c42af19c8 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -214,25 +214,14 @@ def __to_slack(self, block: BaseBlock, sink_name: str) -> List[SlackBlock]: return [] # no reason to crash the entire report def __upload_file_to_slack(self, block: FileBlock, max_log_file_limit_kb: int) -> str: - """Upload a file to slack and return a link to it""" truncated_content = block.truncate_content(max_file_size_bytes=max_log_file_limit_kb * 1000) - try: - with tempfile.NamedTemporaryFile() as f: - f.write(truncated_content) - f.flush() - - # Use files_upload_v2 method (newer API) - result = self.slack_client.files_upload_v2( - title=block.filename, - file=f.name, - filename=block.filename - ) - return result["file"]["permalink"] - except Exception as e: - logging.error(f"Error uploading file {block.filename} to Slack: {e}") - # Return a descriptive message rather than failing - return f"Error uploading {block.filename} - {str(e)}" + """Upload a file to slack and return a link to it""" + with tempfile.NamedTemporaryFile() as f: + f.write(truncated_content) + f.flush() + result = self.slack_client.files_upload_v2(title=block.filename, file=f.name, filename=block.filename) + return result["file"]["permalink"] def prepare_slack_text(self, message: str, max_log_file_limit_kb: int, files: List[FileBlock] = []): if files: @@ -537,18 +526,9 @@ def __send_tool_usage(self, parent_thread: str, slack_channel: str, tool_calls: text = "*AI used info from alert and the following tools:*" for tool in tool_calls: - try: - # Use files_upload_v2 method - file_response = self.slack_client.files_upload_v2( - content=tool.result, - title=f"{tool.description}" - ) - - permalink = file_response["file"]["permalink"] - text += f"\n• `<{permalink}|{tool.description}>`" - except Exception as e: - logging.error(f"Error uploading tool result to Slack: {e}") - text += f"\n• `{tool.description}` (upload failed: {str(e)})" + file_response = self.slack_client.files_upload_v2(content=tool.result, title=f"{tool.description}") + permalink = file_response["file"]["permalink"] + text += f"\n• `<{permalink}|{tool.description}>`" self.slack_client.chat_postMessage( channel=slack_channel, From 787846a7b6e20f937eacd6ffd5efa0f7a3779f45 Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Thu, 27 Mar 2025 11:25:01 +0200 Subject: [PATCH 10/42] incorporate feedback from igor --- src/robusta/integrations/slack/sender.py | 30 +++++++++++------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index c42af19c8..152e5bf6b 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -369,11 +369,15 @@ def __create_finding_header( title = finding.title.removeprefix("[RESOLVED] ") sev = finding.severity - # Create the title section similar to JIRA format + # Create the title with status and name prominently displayed as per user feedback + status_text = "Firing" if status == FindingStatus.FIRING else "Resolved" + status_emoji = "āš ļø" if status == FindingStatus.FIRING else "āœ…" + + # Format: Status emoji [Status] AlertName if platform_enabled and include_investigate_link: - title_text = f"*<{finding.get_investigate_uri(self.account_id, self.cluster_name)}|{title}>*" + title_text = f"{status_emoji} *[{status_text}] <{finding.get_investigate_uri(self.account_id, self.cluster_name)}|{title}>*" else: - title_text = f"*{title}*" + title_text = f"{status_emoji} *[{status_text}] {title}*" title_block = { "type": "section", @@ -397,12 +401,8 @@ def __create_finding_header( else: alert_type = "Notification" - # Create a context block with metadata similar to JIRA format + # Create a context block with metadata but without duplicating status info context_elements = [ - { - "type": "mrkdwn", - "text": f"{status.to_emoji()} Status: {status_text}" - }, { "type": "mrkdwn", "text": f":bell: Type: {alert_type}" @@ -639,6 +639,12 @@ def send_finding_to_slack( if finding.title: header_blocks = self.__create_finding_header(finding, status, platform_enabled, sink_params.investigate_link) + # Description handling - moved above the buttons + if finding.description: + # Always show description immediately after title + description_text = finding.description + blocks.append(MarkdownBlock(description_text)) + links_block: LinksBlock = self.__create_links( finding, platform_enabled, sink_params.investigate_link, sink_params.prefer_redirect_to_platform ) @@ -647,14 +653,6 @@ def send_finding_to_slack( if HOLMES_ENABLED and HOLMES_ASK_SLACK_BUTTON_ENABLED: blocks.append(self.__create_holmes_callback(finding)) - # Description handling - if finding.description: - # We'll put the description in the attachment rather than the main message - description_text = finding.description - # No prefixes, just the description itself - - attachment_blocks.append(MarkdownBlock(description_text)) - unfurl = True all_file_blocks = [] # Collect all file blocks to be handled specially From d72e001c66aa7224149c8c313f93eb9e2ee27c7d Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Mon, 21 Apr 2025 22:34:10 +0300 Subject: [PATCH 11/42] add templating --- docs/configuration/sinks/slack.rst | 75 +++ src/robusta/core/reporting/base.py | 36 ++ .../core/sinks/slack/slack_sink_params.py | 9 +- .../core/sinks/slack/templates/README.md | 73 +++ .../core/sinks/slack/templates/__init__.py | 1 + .../core/sinks/slack/templates/header.j2 | 36 ++ .../sinks/slack/templates/template_loader.py | 82 +++ src/robusta/integrations/slack/sender.py | 104 +++- test_slack_integration.py | 541 ++++++++++++++++++ 9 files changed, 950 insertions(+), 7 deletions(-) create mode 100644 src/robusta/core/sinks/slack/templates/README.md create mode 100644 src/robusta/core/sinks/slack/templates/__init__.py create mode 100644 src/robusta/core/sinks/slack/templates/header.j2 create mode 100644 src/robusta/core/sinks/slack/templates/template_loader.py create mode 100755 test_slack_integration.py diff --git a/docs/configuration/sinks/slack.rst b/docs/configuration/sinks/slack.rst index 67ec37ce8..84e326e0c 100644 --- a/docs/configuration/sinks/slack.rst +++ b/docs/configuration/sinks/slack.rst @@ -181,3 +181,78 @@ your own. This is not recommended for most companies due to the added complexity When using a custom Slack app, callback buttons are not supported due to complexities in how Slack handles incoming messages. :ref:`Contact us if you need assistance. ` +Message Templating +------------------------------------------------------------------- + +Slack messages can be customized using Jinja2 templates. Robusta includes default templates that match the standard format, but you can override them for custom formatting. + +To use custom templates, add them to the ``custom_templates`` parameter: + +.. code-block:: yaml + + sinksConfig: + - slack_sink: + name: main_slack_sink + slack_channel: "#alerts" + api_key: xoxb-112... + custom_templates: + header.j2: | + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{{ status_emoji }} *CUSTOM ALERT: {{ title }}*" + } + } + + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": ":bell: {{ alert_type }} on cluster {{ cluster_name }}" + }, + { + "type": "mrkdwn", + "text": "{{ severity_emoji }} {{ severity }}" + } + ] + } + +Templates use Slack's Block Kit format and must generate valid JSON. Each template block is separated by double newlines (``\n\n``). + +Available template variables: + ++-----------------------------+-------------------------------------------------------------+ +| Variable | Description | ++=============================+=============================================================+ +| ``title`` | The alert title | ++-----------------------------+-------------------------------------------------------------+ +| ``status_text`` | "Firing" or "Resolved" | ++-----------------------------+-------------------------------------------------------------+ +| ``status_emoji`` | "āš ļø" (for firing) or "āœ…" (for resolved) | ++-----------------------------+-------------------------------------------------------------+ +| ``severity`` | Alert severity (e.g., "Warning", "Critical") | ++-----------------------------+-------------------------------------------------------------+ +| ``severity_emoji`` | Emoji for the severity level | ++-----------------------------+-------------------------------------------------------------+ +| ``alert_type`` | "Alert", "K8s Event", or "Notification" | ++-----------------------------+-------------------------------------------------------------+ +| ``cluster_name`` | The name of the cluster | ++-----------------------------+-------------------------------------------------------------+ +| ``platform_enabled`` | Boolean indicating if Robusta platform is enabled | ++-----------------------------+-------------------------------------------------------------+ +| ``include_investigate_link``| Boolean for including investigate link | ++-----------------------------+-------------------------------------------------------------+ +| ``investigate_uri`` | URI for investigation | ++-----------------------------+-------------------------------------------------------------+ +| ``resource_text`` | Resource identifier (e.g., "Pod/namespace/name") | ++-----------------------------+-------------------------------------------------------------+ +| ``resource_emoji`` | Emoji for the resource type | ++-----------------------------+-------------------------------------------------------------+ +| ``finding`` | The complete finding object as JSON | ++-----------------------------+-------------------------------------------------------------+ + +Currently available templates: + +* ``header.j2`` - The header section of alert notifications \ No newline at end of file diff --git a/src/robusta/core/reporting/base.py b/src/robusta/core/reporting/base.py index 9a53d0e43..b7dade26a 100644 --- a/src/robusta/core/reporting/base.py +++ b/src/robusta/core/reporting/base.py @@ -149,6 +149,14 @@ def __init__( def __str__(self): return f"annotations: {self.annotations} Enrichment: {self.blocks} " + + def to_dict(self): + return { + "blocks": [block.dict() for block in self.blocks], + "annotations": self.annotations, + "enrichment_type": self.enrichment_type, + "title": self.title, + } class FilterableScopeMatcher(BaseScopeMatcher): @@ -404,3 +412,31 @@ def __calculate_fingerprint(subject: FindingSubject, source: FindingSource, aggr # if not, generate with logic similar to alertmanager s = f"{subject.subject_type},{subject.name},{subject.namespace},{subject.node},{source.value}{aggregation_key}" return hashlib.sha256(s.encode()).hexdigest() + + def to_json(self): + return { + "title": self.title, + "aggregation_key": self.aggregation_key, + "severity": self.severity.name, + "source": self.source.name, + "description": self.description, + "subject": { + "name": self.subject.name, + "subject_type": self.subject.subject_type.value, + "namespace": self.subject.namespace, + "node": self.subject.node, + "container": self.subject.container, + "labels": self.subject.labels, + "annotations": self.subject.annotations, + }, + "finding_type": self.finding_type.name, + "failure": self.failure, + "creation_date": self.creation_date, + "fingerprint": self.fingerprint, + "starts_at": self.starts_at, + "ends_at": self.ends_at, + "add_silence_url": self.add_silence_url, + "silence_labels": self.silence_labels, + "enrichments": [enrichment.to_dict() for enrichment in self.enrichments], + "links": [link.dict() for link in self.links], + } diff --git a/src/robusta/core/sinks/slack/slack_sink_params.py b/src/robusta/core/sinks/slack/slack_sink_params.py index 2badde2a6..36e19a849 100644 --- a/src/robusta/core/sinks/slack/slack_sink_params.py +++ b/src/robusta/core/sinks/slack/slack_sink_params.py @@ -2,7 +2,7 @@ from robusta.core.sinks.sink_config import SinkConfigBase from robusta.core.sinks.common import ChannelTransformer -from typing import Optional +from typing import Optional, Dict from pydantic import validator @@ -12,6 +12,11 @@ class SlackSinkParams(SinkBaseParams): channel_override: Optional[str] = None max_log_file_limit_kb: int = 1000 investigate_link: bool = True + send_svg: bool = True + prefer_redirect_to_platform: bool = True + + # Template customization options + custom_templates: Optional[Dict[str, str]] = None # Template name -> custom template content @classmethod def _supports_grouping(cls): @@ -30,4 +35,4 @@ class SlackSinkConfigWrapper(SinkConfigBase): slack_sink: SlackSinkParams def get_params(self) -> SinkBaseParams: - return self.slack_sink + return self.slack_sink \ No newline at end of file diff --git a/src/robusta/core/sinks/slack/templates/README.md b/src/robusta/core/sinks/slack/templates/README.md new file mode 100644 index 000000000..71ab4255d --- /dev/null +++ b/src/robusta/core/sinks/slack/templates/README.md @@ -0,0 +1,73 @@ +# Slack Message Templates + +This directory contains the Jinja2 templates used to render Slack messages. + +## How Templates Work + +Slack messages are rendered using [Jinja2](https://jinja.palletsprojects.com/) templates. Each template produces one or more Slack Block Kit blocks in JSON format. + +Templates are separated by double newlines (`\n\n`) to indicate separate blocks. Each block must be valid JSON that conforms to the [Slack Block Kit](https://api.slack.com/block-kit) format. + +## Available Templates + +- `header.j2`: The header section of alert notifications, including title and metadata + +## Customizing Templates + +Users can customize templates in the Robusta configuration by providing their own template content: + +```yaml +sinks: + slack: + slack_sink: + name: slack + slack_channel: "#alerts" + api_key: "${SLACK_TOKEN}" + custom_templates: + header.j2: | + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{{ status_emoji }} *CUSTOM ALERT: {{ title }}*" + } + } + + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": ":bell: {{ alert_type }} on cluster {{ cluster_name }}" + }, + { + "type": "mrkdwn", + "text": "{{ severity_emoji }} {{ severity }}" + } + ] + } +``` + +## Template Variables + +Each template has access to different variables. Here are the variables available in the `header.j2` template: + +| Variable | Description | +|----------|-------------| +| `title` | The alert title | +| `status_text` | "Firing" or "Resolved" | +| `status_emoji` | "āš ļø" (for firing) or "āœ…" (for resolved) | +| `severity` | Alert severity (e.g., "Warning", "Critical") | +| `severity_emoji` | Emoji for the severity level | +| `alert_type` | "Alert", "K8s Event", or "Notification" | +| `cluster_name` | The name of the cluster | +| `platform_enabled` | Boolean indicating if Robusta platform is enabled | +| `include_investigate_link` | Boolean indicating if investigate link should be included | +| `investigate_uri` | URI for investigation | +| `resource_text` | Resource identifier (e.g., "Pod/namespace/name") | +| `resource_emoji` | Emoji for the resource type | +| `finding` | The complete finding object as JSON | + +## Fallback Behavior + +If Jinja2 is not available, a built-in fallback implementation will be used that produces the same output as the default template. \ No newline at end of file diff --git a/src/robusta/core/sinks/slack/templates/__init__.py b/src/robusta/core/sinks/slack/templates/__init__.py new file mode 100644 index 000000000..d38fa3fc2 --- /dev/null +++ b/src/robusta/core/sinks/slack/templates/__init__.py @@ -0,0 +1 @@ +# Slack message templates package diff --git a/src/robusta/core/sinks/slack/templates/header.j2 b/src/robusta/core/sinks/slack/templates/header.j2 new file mode 100644 index 000000000..82ea7b1aa --- /dev/null +++ b/src/robusta/core/sinks/slack/templates/header.j2 @@ -0,0 +1,36 @@ +{# Default template for Slack message headers #} +{# This creates a JIRA-style header with title and context blocks #} + +{# First create the title block with status #} +{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{{ status_emoji }} *[{{ status_text }}] {% if platform_enabled and include_investigate_link %}<{{ investigate_uri }}|{{ title }}>{% else %}{{ title }}{% endif %}*" + } +} + +{# Then create the context block with metadata #} +{ + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": ":bell: Type: {{ alert_type }}" + }, + { + "type": "mrkdwn", + "text": "{{ severity_emoji }} Severity: {{ severity }}" + }, + { + "type": "mrkdwn", + "text": ":globe_with_meridians: Cluster: {{ cluster_name }}" + } + {% if resource_text %} + ,{ + "type": "mrkdwn", + "text": "{{ resource_emoji }} Resource: {{ resource_text }}" + } + {% endif %} + ] +} \ No newline at end of file diff --git a/src/robusta/core/sinks/slack/templates/template_loader.py b/src/robusta/core/sinks/slack/templates/template_loader.py new file mode 100644 index 000000000..64b561792 --- /dev/null +++ b/src/robusta/core/sinks/slack/templates/template_loader.py @@ -0,0 +1,82 @@ +import json +import logging +import os +from typing import Dict, Any, Optional, List, Tuple + +from jinja2 import Environment, FileSystemLoader, Template, select_autoescape + +# Get the directory where our templates are stored +TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__))) + + +class SlackTemplateLoader: + """ + Loads and renders Jinja2 templates for Slack messages. + """ + + def __init__(self): + """Initialize the template environment.""" + self.env = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), + autoescape=select_autoescape(["html", "xml"]), + trim_blocks=True, + lstrip_blocks=True, + ) + # Cache for templates + self._templates: Dict[str, Template] = {} + + def get_template(self, template_name: str) -> Template: + """ + Get a template by name, loading from file if not already cached. + + Args: + template_name: The name of the template file (e.g., "header.j2") + + Returns: + A Jinja2 Template object + """ + if template_name not in self._templates: + try: + self._templates[template_name] = self.env.get_template(template_name) + except Exception as e: + logging.error(f"Error loading template {template_name}: {e}") + # Return a simple default template as fallback + return Template("Template loading error") + + return self._templates[template_name] + + def render_to_blocks(self, template_name: str, context: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Render a template using the provided context and parse the result as JSON to get Slack blocks. + + Args: + template_name: The name of the template file + context: Dictionary of variables to pass to the template + + Returns: + List of Slack block objects (dictionaries) + """ + template = self.get_template(template_name) + + try: + rendered = template.render(**context) + + # Split by newlines to get multiple blocks and parse each as JSON + blocks = [] + for block_str in rendered.strip().split("\n\n"): + if block_str.strip(): + try: + block = json.loads(block_str) + blocks.append(block) + except json.JSONDecodeError as e: + logging.error(f"Error parsing JSON from template output: {e}") + logging.debug(f"Problematic JSON: {block_str}") + + return blocks + except Exception as e: + logging.error(f"Error rendering template {template_name}: {e}") + return [] + + +# Singleton instance +template_loader = SlackTemplateLoader() \ No newline at end of file diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 152e5bf6b..a73e802d7 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -1,5 +1,7 @@ import copy +import json import logging +import os import ssl import tempfile from datetime import datetime, timedelta @@ -13,6 +15,14 @@ from slack_sdk.http_retry import all_builtin_retry_handlers from slack_sdk.errors import SlackApiError +try: + # Import is optional since jinja2 is an optional dependency + from jinja2 import Template + from robusta.core.sinks.slack.templates.template_loader import template_loader + JINJA2_AVAILABLE = True +except ImportError: + JINJA2_AVAILABLE = False + from robusta.core.model.base_params import AIInvestigateParams, ResourceInfo from robusta.core.model.env_vars import ( ADDITIONAL_CERTIFICATE, @@ -364,11 +374,98 @@ def __create_holmes_callback(self, finding: Finding) -> CallbackBlock: ) def __create_finding_header( - self, finding: Finding, status: FindingStatus, platform_enabled: bool, include_investigate_link: bool + self, finding: Finding, status: FindingStatus, platform_enabled: bool, include_investigate_link: bool, + sink_params = None ) -> List[SlackBlock]: title = finding.title.removeprefix("[RESOLVED] ") sev = finding.severity + # Determine if we should use Jinja2 templates + use_jinja = JINJA2_AVAILABLE + # Check if the user has provided a custom template + custom_template = None + if sink_params and sink_params.custom_templates and "header.j2" in sink_params.custom_templates: + custom_template = sink_params.custom_templates["header.j2"] + + if use_jinja: + # Prepare data for template + status_text = "Firing" if status == FindingStatus.FIRING else "Resolved" + status_emoji = "āš ļø" if status == FindingStatus.FIRING else "āœ…" + investigate_uri = finding.get_investigate_uri(self.account_id, self.cluster_name) if platform_enabled else "" + + # Get alert type information + if finding.source == FindingSource.PROMETHEUS: + alert_type = "Alert" + elif finding.source == FindingSource.KUBERNETES_API_SERVER: + alert_type = "K8s Event" + else: + alert_type = "Notification" + + # Prepare resource text and emoji if available + resource_text = "" + resource_emoji = ":package:" + + if finding.subject: + subject_kind = finding.subject.subject_type.value + subject_namespace = finding.subject.namespace + subject_name = finding.subject.name + + if subject_kind and subject_name: + # Choose emoji based on kind + if subject_kind.lower() == "pod": + resource_emoji = ":ship:" + elif subject_kind.lower() == "deployment": + resource_emoji = ":package:" + elif subject_kind.lower() == "node": + resource_emoji = ":computer:" + elif subject_kind.lower() == "service": + resource_emoji = ":link:" + elif subject_kind.lower() == "job": + resource_emoji = ":clock1:" + elif subject_kind.lower() == "statefulset": + resource_emoji = ":chains:" + + # Format as Kind/Namespace/Name + if subject_namespace: + resource_text = f"{subject_kind}/{subject_namespace}/{subject_name}" + else: + resource_text = f"{subject_kind}/{subject_name}" + + # Prepare template context + template_context = { + "title": title, + "status_text": status_text, + "status_emoji": status_emoji, + "severity": sev.name.capitalize(), + "severity_emoji": sev.to_emoji(), + "alert_type": alert_type, + "cluster_name": self.cluster_name, + "platform_enabled": platform_enabled, + "include_investigate_link": include_investigate_link, + "investigate_uri": investigate_uri, + "resource_text": resource_text, + "resource_emoji": resource_emoji, + "finding": finding.to_json() if hasattr(finding, "to_json") else {} + } + + # If custom template provided, use it directly with Jinja + if custom_template: + try: + template = Template(custom_template) + rendered_blocks = [] + for block_str in template.render(**template_context).strip().split("\n\n"): + if block_str.strip(): + block = json.loads(block_str) + rendered_blocks.append(block) + return rendered_blocks + except Exception as e: + logging.error(f"Error rendering custom template: {e}") + # Fall back to file-based template + + # Use file-based template + return template_loader.render_to_blocks("header.j2", template_context) + + # Fallback to hard-coded blocks if Jinja2 is not available # Create the title with status and name prominently displayed as per user feedback status_text = "Firing" if status == FindingStatus.FIRING else "Resolved" status_emoji = "āš ļø" if status == FindingStatus.FIRING else "āœ…" @@ -390,9 +487,6 @@ def __create_finding_header( # Create metadata line with "cluster" instead of "source" cluster_text = f"Cluster: {self.cluster_name}" - # Simplify status to just Firing/Resolved - status_text = "Firing" if status == FindingStatus.FIRING else "Resolved" - # Get type information separately if finding.source == FindingSource.PROMETHEUS: alert_type = "Alert" @@ -637,7 +731,7 @@ def send_finding_to_slack( # Get JIRA-style header blocks if finding.title: - header_blocks = self.__create_finding_header(finding, status, platform_enabled, sink_params.investigate_link) + header_blocks = self.__create_finding_header(finding, status, platform_enabled, sink_params.investigate_link, sink_params) # Description handling - moved above the buttons if finding.description: diff --git a/test_slack_integration.py b/test_slack_integration.py new file mode 100755 index 000000000..8a2bbdd60 --- /dev/null +++ b/test_slack_integration.py @@ -0,0 +1,541 @@ +#!/usr/bin/env python3 +""" +This script is a test harness for the Robusta Slack integration. +It allows you to quickly iterate on Slack message formatting by sending +test messages with mock data. +""" + +import os +import sys +import logging +import json +from typing import List, Dict, Any, Optional +from datetime import datetime + +# Add the src directory to the Python path so we can import the modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) + +from robusta.integrations.slack.sender import SlackSender, JINJA2_AVAILABLE +from robusta.core.reporting.base import Finding, FindingSource, FindingSeverity, FindingStatus, FindingSubject, Link, EnrichmentType +from robusta.core.reporting.consts import FindingSubjectType, FindingType +from robusta.core.reporting.base import LinkType +from robusta.core.reporting.blocks import ( + MarkdownBlock, + HeaderBlock, + DividerBlock, + TableBlock, + ListBlock, + LinkProp, + LinksBlock, + CallbackBlock, + CallbackChoice, + FileBlock +) +from robusta.core.model.base_params import ResourceInfo +from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams +from robusta.core.playbooks.internal.ai_integration import ask_holmes + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") + +# You'll need to provide a Slack API token to run this script +# You can get one from https://api.slack.com/apps +# For testing, you can hardcode a token here, but don't commit it to the repo +# By default, try to get token from env var +SLACK_TOKEN = os.environ.get("SLACK_TOKEN", "xoxb-your-actual-token-here") +# The Slack channel to send messages to +SLACK_CHANNEL = os.environ.get("SLACK_CHANNEL", "test-robusta") + +# In a real scenario, we'd use @action decorator, but for testing +# we'll just remove the CallbackBlock as it requires the full Robusta action system +# We'll use a simple MarkdownBlock with button simulation instead + +# Create mock data for the Finding +def create_test_finding(title: str, severity: FindingSeverity = FindingSeverity.INFO) -> Finding: + """Create a test finding with the given title and severity""" + # Create a subject first with the correct type + subject = FindingSubject( + name="test-pod", + namespace="default", + subject_type=FindingSubjectType.TYPE_POD, + labels={ + "app": "test-app", + "environment": "test" + }, + annotations={ + "description": "Test annotation" + } + ) + + finding = Finding( + title=title, + source=FindingSource.PROMETHEUS, + severity=severity, + aggregation_key=f"test-{title}", + finding_type=FindingType.ISSUE, + description="This is a test finding for Slack message formatting", + subject=subject + ) + + # We need to mock the service since TopServiceResolver won't work in test environment + # Create a simple mock for the service object + class MockService: + def __init__(self, name, namespace, resource_type): + self.name = name + self.namespace = namespace + self.resource_type = resource_type + + def get_resource_key(self): + return f"{self.namespace}/{self.name}" + + # Set the service directly + finding.service = MockService( + name="test-deployment", + namespace="default", + resource_type="Deployment" + ) + + # Add links as proper Link objects + + finding.links = [ + Link(url="https://example.com/logs", name="View Logs"), + Link(url="https://example.com/grafana", name="Grafana", type=LinkType.PROMETHEUS_GENERATOR_URL) + ] + + return finding + +def add_basic_blocks(finding: Finding) -> None: + """Add some basic blocks to the finding""" + finding.add_enrichment([ + HeaderBlock("Kubernetes Resource Information"), + MarkdownBlock("This alert was triggered by high resource usage"), + TableBlock( + rows=[ + ["Namespace", "default"], + ["Pod", "test-pod"], + ["Container", "test-container"], + ["CPU Usage", "250m"], + ["Memory Usage", "512Mi"], + ], + headers=["Property", "Value"], + table_name="Resource Usage", + ), + DividerBlock(), + MarkdownBlock("*Recent Events:*"), + ListBlock([ + "Pod started at 08:15:30", + "CPU usage spiked at 08:45:22", + "Memory usage increased at 08:47:15" + ]), + LinksBlock(links=[ + LinkProp(text="View Pod Details", url="https://example.com/pod"), + LinkProp(text="View Container Logs", url="https://example.com/logs") + ]), + # Instead of using CallbackBlock which requires the @action decorator + MarkdownBlock("*Available Actions:* (Simulated buttons)\n• Restart Pod\n• Scale Deployment") + ]) + +def add_complex_table(finding: Finding) -> None: + """Add a more complex table to the finding""" + finding.add_enrichment([ + HeaderBlock("Detailed Resource Metrics"), + TableBlock( + rows=[ + ["pod-1", "Running", "0.25", "256Mi", "node-1", "app=web,tier=frontend"], + ["pod-2", "Running", "0.50", "512Mi", "node-1", "app=web,tier=frontend"], + ["pod-3", "CrashLoopBackOff", "0.10", "128Mi", "node-2", "app=db,tier=backend"], + ["pod-4", "Running", "0.75", "1024Mi", "node-2", "app=api,tier=backend"], + ["pod-5", "Pending", "0", "0", "None", "app=cache,tier=backend"], + ], + headers=["Pod", "Status", "CPU", "Memory", "Node", "Labels"], + table_name="Cluster Pod Overview", + ) + ]) + +def create_holmes_callback(finding: Finding) -> None: + """Add a Holmes AI callback to the finding""" + # Instead of using a real callback, we'll simulate it with markdown + finding.add_enrichment([ + MarkdownBlock("*AI Investigation:*"), + MarkdownBlock("šŸ¤– *Ask Holmes AI* to investigate this alert (simulated button)"), + MarkdownBlock("_Holmes would analyze this alert and provide insights on cause and remediation._") + ]) + +def create_crash_loop_finding() -> Finding: + """Create a sample crash loop finding with exact requested data""" + subject = FindingSubject( + name="payment-processing-worker-747ccfb9db-r7z7m", + namespace="default", + subject_type=FindingSubjectType.TYPE_POD, + node="gke-1-4d3c8387-d24h", + labels={ + "app": "payment-processing-worker", + "pod-template-hash": "747ccfb9db" + }, + annotations={} + ) + + finding = Finding( + title="Crashing pod payment-processing-worker-747ccfb9db-r7z7m in namespace default", + source=FindingSource.KUBERNETES_API_SERVER, + severity=FindingSeverity.HIGH, + aggregation_key="CrashLoopBackoff", + finding_type=FindingType.ISSUE, + subject=subject, + failure=True, + fingerprint="d7c6e2ac798ee28c8a4ac6c8c33de52e60f0b019cd10d78ac04831d44cf86389", + starts_at=datetime(2025, 3, 23, 20, 8, 43, 822328) + ) + + # Add the crash info enrichment + finding.add_enrichment([ + TableBlock( + rows=[ + ["Container", "payment-processing-container"], + ["Restarts", 130], + ["Status", "WAITING"], + ["Reason", "CrashLoopBackOff"] + ], + headers=["label", "value"], + table_name="*Crash Info*", + metadata={"format": "vertical"} + ), + TableBlock( + rows=[ + ["Status", "TERMINATED"], + ["Reason", "Completed"], + ["Started at", "2025-03-23T18:08:32Z"], + ["Finished at", "2025-03-23T18:08:32Z"] + ], + headers=["label", "value"], + table_name="*Previous Container*", + metadata={"format": "vertical"} + ) + ], title="Container Crash information", enrichment_type=EnrichmentType.crash_info) + + # Add the logs enrichment + finding.add_enrichment([ + FileBlock( + filename="payment-processing-worker-747ccfb9db-r7z7m.log", + contents=b'Environment variable DEPLOY_ENV is undefined\n' + ) + ], title="Logs", enrichment_type=EnrichmentType.text_file) + + return finding + +def create_node_saturation_finding() -> Finding: + """Create a node saturation finding with exact requested data""" + subject = FindingSubject( + name="gke-1-4d3c8387-c4bp", + subject_type=FindingSubjectType.TYPE_NODE, + namespace="default", + node="gke-1-4d3c8387-c4bp", + container="node-exporter", + labels={ + "beta.kubernetes.io/arch": "amd64", + "beta.kubernetes.io/instance-type": "e2-standard-2", + "beta.kubernetes.io/os": "linux", + "cloud.google.com/gke-boot-disk": "pd-balanced", + "cloud.google.com/gke-container-runtime": "containerd", + "cloud.google.com/gke-cpu-scaling-level": "2", + "cloud.google.com/gke-logging-variant": "DEFAULT", + "cloud.google.com/gke-max-pods-per-node": "110", + "cloud.google.com/gke-memory-gb-scaling-level": "8", + "cloud.google.com/gke-nodepool": "pool-1", + "cloud.google.com/gke-os-distribution": "cos", + "cloud.google.com/gke-provisioning": "spot", + "cloud.google.com/gke-spot": "true", + "cloud.google.com/gke-stack-type": "IPV4", + "cloud.google.com/machine-family": "e2", + "cloud.google.com/private-node": "false", + "failure-domain.beta.kubernetes.io/region": "us-central1", + "failure-domain.beta.kubernetes.io/zone": "us-central1-c", + "kubernetes.io/arch": "amd64", + "kubernetes.io/hostname": "gke-4d3c8387-c4bp" + }, + annotations={} + ) + + finding = Finding( + title="System saturated, load per core is very high.", + source=FindingSource.PROMETHEUS, + severity=FindingSeverity.LOW, + aggregation_key="NodeSystemSaturation", + finding_type=FindingType.ISSUE, + subject=subject, + description="System load per core at 10.128.0.77:9104 has been above 2 for the last 15 minutes, is currently at 5.22.\nThis might indicate this instance resources saturation and can cause it becoming unresponsive.\n", + starts_at=datetime(2025, 3, 23, 20, 9, 4, 838000) + ) + + # Could add enrichments here if needed + + return finding + +def create_datadog_agent_crash_finding() -> Finding: + """Create a datadog agent crash finding""" + subject = FindingSubject( + name="datadog-agent-w49mv", + subject_type=FindingSubjectType.TYPE_POD, + namespace="default", + node="gke-1-4d3c8387-mm23", + labels={ + "agent.datadoghq.com/component": "agent", + "agent.datadoghq.com/name": "datadog", + "agent.datadoghq.com/provider": "", + "app.kubernetes.io/component": "agent", + "app.kubernetes.io/instance": "datadog-agent", + "app.kubernetes.io/managed-by": "datadog-operator", + "app.kubernetes.io/name": "datadog-agent-deployment", + "app.kubernetes.io/part-of": "default-datadog" + }, + annotations={} + ) + + finding = Finding( + title="Crashing pod datadog-agent-w49mv in namespace default", + source=FindingSource.KUBERNETES_API_SERVER, + severity=FindingSeverity.HIGH, + aggregation_key="CrashLoopBackoff", + finding_type=FindingType.ISSUE, + subject=subject, + failure=True, + starts_at=datetime(2025, 3, 23, 20, 9, 2, 211000) + ) + + # Could add crash info enrichment here if needed + + return finding + +def create_cpu_throttling_finding() -> Finding: + """Create a CPU throttling finding with exact requested data""" + subject = FindingSubject( + name="vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm", + subject_type=FindingSubjectType.TYPE_POD, + namespace="default", + node="gke-4ksv", + container="vmagent", + labels={ + "app.kubernetes.io/component": "monitoring", + "app.kubernetes.io/instance": "vmks-victoria-metrics-k8s-stack", + "app.kubernetes.io/name": "vmagent", + "managed-by": "vm-operator", + "pod-template-hash": "6dcd499cb5", + "alertname": "CPUThrottlingHigh", + "container": "vmagent", + "namespace": "default", + "pod": "vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm", + "prometheus": "default/robusta-kube-prometheus-st-prometheus", + "severity": "info" + }, + annotations={ + "description": "71.13% throttling of CPU in namespace default for container vmagent in pod vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm.", + "runbook_url": "https://runbooks.prometheus-operator.dev/runbooks/kubernetes/cputhrottlinghigh", + "summary": "Processes experience elevated CPU throttling." + } + ) + + # Create the finding + finding = Finding( + title="Processes experience elevated CPU throttling.", + source=FindingSource.PROMETHEUS, + severity=FindingSeverity.INFO, + aggregation_key="CPUThrottlingHigh", + finding_type=FindingType.ISSUE, + subject=subject, + description="71.13% throttling of CPU in namespace default for container vmagent in pod vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm.", + failure=True, + fingerprint="9674d957b35e0e9f", + starts_at=datetime(2025, 3, 22, 10, 22, 10, 219000), + add_silence_url=True, + silence_labels={ + "alertname": "CPUThrottlingHigh", + "container": "vmagent", + "namespace": "default", + "pod": "vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm", + "prometheus": "default/robusta-kube-prometheus-st-prometheus", + "severity": "info" + } + ) + + # Add explanation enrichment + finding.add_enrichment([ + MarkdownBlock('šŸ“˜ *Alert Explanation:* This pod is throttled due to its CPU limit. This can occur even when CPU usage is far below the limit. '), + MarkdownBlock("šŸ›  *Robusta's Recommendation:* Remove this pod's CPU limit entirely. ") + ], annotations={"unfurl": False}) + + # Instead of adding an SVG file which can cause JSON serialization issues, + # Let's add a simpler enrichment with just a table for CPU usage + finding.add_enrichment([ + TableBlock( + rows=[ + ["Current CPU Usage", "19.5m"], + ["CPU Request", "100m"], + ["CPU Limit", "300m"], + ["Throttling Percentage", "71.13%"], + ["Duration", "Last 5 minutes"] + ], + headers=["Metric", "Value"], + table_name="*CPU Usage*" + ) + ], title="Resources", enrichment_type=EnrichmentType.graph) + + # We're not including the SVG graph anymore to avoid JSON serialization issues + + # Add alert labels enrichment + finding.add_enrichment([ + TableBlock( + rows=[ + ["alertname", "CPUThrottlingHigh"], + ["container", "vmagent"], + ["namespace", "default"], + ["pod", "vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm"], + ["prometheus", "default/robusta-kube-prometheus-st-prometheus"], + ["severity", "info"] + ], + headers=["label", "value"], + table_name="*Alert labels*", + metadata={"format": "vertical"} + ) + ], title="Alert labels", enrichment_type=EnrichmentType.alert_labels, annotations={"attachment": True}) + + # Add Prometheus link + finding.add_link( + Link( + url="http://robusta-kube-prometheus-st-prometheus.default:9090/graph?g0.expr=sum+by+%28cluster%2C+container%2C+pod%2C+namespace%29+%28increase%28container_cpu_cfs_throttled_periods_total%7Bcontainer%21%3D%22%22%7D%5B5m%5D%29%29+%2F+sum+by+%28cluster%2C+container%2C+pod%2C+namespace%29+%28increase%28container_cpu_cfs_periods_total%5B5m%5D%29%29+%3E+%2825+%2F+100%29&g0.tab=1", + name="View Graph", + type=LinkType.PROMETHEUS_GENERATOR_URL + ) + ) + + return finding + +def main(): + if not SLACK_TOKEN: + logging.error("SLACK_TOKEN environment variable not set. Please set it to proceed.") + sys.exit(1) + + # Check if Jinja2 is available + if JINJA2_AVAILABLE: + logging.info("Jinja2 is available, using templated headers") + else: + logging.warning("Jinja2 is not available! Using fallback header format.") + logging.warning("To use templates, install Jinja2: pip install jinja2>=3.1.5") + + # Initialize the SlackSender + sender = SlackSender( + slack_token=SLACK_TOKEN, + account_id="test-account", + cluster_name="test-cluster", + signing_key="test-signing-key", + slack_channel=SLACK_CHANNEL + ) + + # Create the SlackSinkParams + sink_params = SlackSinkParams( + name="test-sink", + slack_channel=SLACK_CHANNEL, + api_key=SLACK_TOKEN, + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000 + ) + + # Create a SlackSinkParams with a custom template + custom_template_params = SlackSinkParams( + name="custom-template-sink", + slack_channel=SLACK_CHANNEL, + api_key=SLACK_TOKEN, + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000, + custom_templates={ + "header.j2": """ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{{ status_emoji }} *CUSTOM TEMPLATE: {{ title }}*" + } + } + + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": ":rotating_light: {{ alert_type }} on cluster {{ cluster_name }}" + }, + { + "type": "mrkdwn", + "text": "{{ severity_emoji }} {{ severity }} severity" + } + {% if resource_text %} + ,{ + "type": "mrkdwn", + "text": "{{ resource_emoji }} {{ resource_text }}" + } + {% endif %} + ] + } + """ + } + ) + + # Create all findings + crash_loop_finding = create_crash_loop_finding() + node_saturation_finding = create_node_saturation_finding() + datadog_agent_crash_finding = create_datadog_agent_crash_finding() + cpu_throttling_finding = create_cpu_throttling_finding() + + # Send findings to Slack with standard templates + logging.info(f"Sending crash loop finding to Slack channel: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(crash_loop_finding, sink_params, platform_enabled=True) + + logging.info(f"Sending node saturation finding to Slack channel: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(node_saturation_finding, sink_params, platform_enabled=True) + + # Send findings to Slack with custom templates + logging.info(f"Sending datadog agent crash finding to Slack channel with CUSTOM TEMPLATE: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(datadog_agent_crash_finding, custom_template_params, platform_enabled=True) + + logging.info(f"Sending CPU throttling finding to Slack channel with CUSTOM TEMPLATE: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(cpu_throttling_finding, custom_template_params, platform_enabled=True) + + logging.info(f"Test messages sent successfully to Slack channel: {SLACK_CHANNEL}") + + # Uncomment the below code to send the original test findings + """ + # Create test findings with different severities + finding_info = create_test_finding("Test INFO Alert", FindingSeverity.INFO) + finding_low = create_test_finding("Test LOW Alert", FindingSeverity.LOW) + finding_medium = create_test_finding("Test MEDIUM Alert", FindingSeverity.MEDIUM) + finding_high = create_test_finding("Test HIGH Alert", FindingSeverity.HIGH) + + # Add different types of content to each finding + add_basic_blocks(finding_info) + add_complex_table(finding_low) + + # Combine basic and complex content for medium severity + add_basic_blocks(finding_medium) + add_complex_table(finding_medium) + + # Add AI callback for high severity + add_basic_blocks(finding_high) + create_holmes_callback(finding_high) + + # Send all test findings to Slack + logging.info(f"Sending INFO finding to Slack channel: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(finding_info, sink_params, platform_enabled=True) + + logging.info(f"Sending LOW finding to Slack channel: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(finding_low, sink_params, platform_enabled=True) + + logging.info(f"Sending MEDIUM finding to Slack channel: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(finding_medium, sink_params, platform_enabled=True) + + logging.info(f"Sending HIGH finding with Holmes callback to Slack channel: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(finding_high, sink_params, platform_enabled=True) + """ + +if __name__ == "__main__": + main() \ No newline at end of file From c4866c36729cfc856c0f389b31b6572cd29acbaa Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Mon, 21 Apr 2025 22:42:41 +0300 Subject: [PATCH 12/42] remove unused code --- src/robusta/integrations/slack/sender.py | 26 ------------------------ 1 file changed, 26 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index a73e802d7..e41d63b7a 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -552,32 +552,6 @@ def __create_finding_header( return [title_block, context_block] - def __get_enrichment_title(self, enrichment_type: Optional[EnrichmentType]) -> str: - """Get a user-friendly title for an enrichment type""" - if enrichment_type is None: - return "Additional Information" - - titles = { - EnrichmentType.graph: "Performance Graphs", - EnrichmentType.ai_analysis: "AI Analysis", - EnrichmentType.node_info: "Node Information", - EnrichmentType.container_info: "Container Information", - EnrichmentType.k8s_events: "Kubernetes Events", - EnrichmentType.alert_labels: "Alert Labels", - EnrichmentType.diff: "Resource Changes", - EnrichmentType.text_file: "Logs", - EnrichmentType.crash_info: "Crash Information", - EnrichmentType.image_pull_backoff_info: "Image Pull Error", - EnrichmentType.pending_pod_info: "Pod Scheduling Information" - } - - return titles.get(enrichment_type, str(enrichment_type).replace("EnrichmentType.", "").replace("_", " ").title()) - - def __get_enrichment_color(self, enrichment_type: Optional[EnrichmentType], status: FindingStatus) -> str: - """Get a color based on status""" - # We're now using a single color for all attachments based on status - return status.to_color_hex() - def __create_links( self, finding: Finding, From 092f0c82931046221bb054e9ac29283e52558955 Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Mon, 21 Apr 2025 22:42:46 +0300 Subject: [PATCH 13/42] add logs --- src/robusta/integrations/slack/sender.py | 12 ++++++++++++ test_slack_integration.py | 12 ++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index e41d63b7a..012003301 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -794,6 +794,18 @@ def send_finding_to_slack( "color": status.to_color_hex(), "blocks": all_attachment_blocks }] + + # Detailed logging of blocks before sending to Slack + logging.debug( + f"SENDING TO SLACK - FULL BLOCKS DETAIL:\n" + f"CHANNEL: {slack_channel}\n" + f"TITLE: {finding.title}\n" + f"HEADER BLOCKS: {json.dumps(header_blocks, indent=2)}\n" + f"MAIN BLOCKS: {json.dumps(output_blocks, indent=2)}\n" + f"ALL BLOCKS: {json.dumps(all_blocks, indent=2)}\n" + f"ATTACHMENT BLOCKS: {json.dumps(all_attachment_blocks, indent=2)}\n" + f"ATTACHMENTS: {json.dumps(attachments, indent=2)}\n" + ) # Send the message with our JIRA-style headers and attachments resp = self.slack_client.chat_postMessage( diff --git a/test_slack_integration.py b/test_slack_integration.py index 8a2bbdd60..426c37d34 100755 --- a/test_slack_integration.py +++ b/test_slack_integration.py @@ -35,8 +35,11 @@ from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams from robusta.core.playbooks.internal.ai_integration import ask_holmes -# Configure logging -logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +# Configure logging - set to DEBUG to see full block details +logging.basicConfig( + level=logging.DEBUG if os.environ.get("DEBUG", "").lower() == "true" else logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) # You'll need to provide a Slack API token to run this script # You can get one from https://api.slack.com/apps @@ -420,6 +423,11 @@ def main(): else: logging.warning("Jinja2 is not available! Using fallback header format.") logging.warning("To use templates, install Jinja2: pip install jinja2>=3.1.5") + + # Enable detailed block logging if DEBUG=true + if os.environ.get("DEBUG", "").lower() == "true": + logging.info("DEBUG mode enabled - will print detailed block information") + logging.info("Run script with DEBUG=false to disable detailed logging") # Initialize the SlackSender sender = SlackSender( From 8c1a9a830b530733800ba0e2d3121bc15ca90b1e Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Mon, 21 Apr 2025 22:47:52 +0300 Subject: [PATCH 14/42] simplify code - always use jinja --- src/robusta/integrations/slack/sender.py | 192 ++++++----------------- test_slack_integration.py | 9 +- 2 files changed, 50 insertions(+), 151 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 012003301..da5b0dff1 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -15,13 +15,9 @@ from slack_sdk.http_retry import all_builtin_retry_handlers from slack_sdk.errors import SlackApiError -try: - # Import is optional since jinja2 is an optional dependency - from jinja2 import Template - from robusta.core.sinks.slack.templates.template_loader import template_loader - JINJA2_AVAILABLE = True -except ImportError: - JINJA2_AVAILABLE = False +# Since we're assuming Jinja2 is always present, we can import directly +from jinja2 import Template +from robusta.core.sinks.slack.templates.template_loader import template_loader from robusta.core.model.base_params import AIInvestigateParams, ResourceInfo from robusta.core.model.env_vars import ( @@ -380,114 +376,17 @@ def __create_finding_header( title = finding.title.removeprefix("[RESOLVED] ") sev = finding.severity - # Determine if we should use Jinja2 templates - use_jinja = JINJA2_AVAILABLE # Check if the user has provided a custom template custom_template = None if sink_params and sink_params.custom_templates and "header.j2" in sink_params.custom_templates: custom_template = sink_params.custom_templates["header.j2"] - if use_jinja: - # Prepare data for template - status_text = "Firing" if status == FindingStatus.FIRING else "Resolved" - status_emoji = "āš ļø" if status == FindingStatus.FIRING else "āœ…" - investigate_uri = finding.get_investigate_uri(self.account_id, self.cluster_name) if platform_enabled else "" - - # Get alert type information - if finding.source == FindingSource.PROMETHEUS: - alert_type = "Alert" - elif finding.source == FindingSource.KUBERNETES_API_SERVER: - alert_type = "K8s Event" - else: - alert_type = "Notification" - - # Prepare resource text and emoji if available - resource_text = "" - resource_emoji = ":package:" - - if finding.subject: - subject_kind = finding.subject.subject_type.value - subject_namespace = finding.subject.namespace - subject_name = finding.subject.name - - if subject_kind and subject_name: - # Choose emoji based on kind - if subject_kind.lower() == "pod": - resource_emoji = ":ship:" - elif subject_kind.lower() == "deployment": - resource_emoji = ":package:" - elif subject_kind.lower() == "node": - resource_emoji = ":computer:" - elif subject_kind.lower() == "service": - resource_emoji = ":link:" - elif subject_kind.lower() == "job": - resource_emoji = ":clock1:" - elif subject_kind.lower() == "statefulset": - resource_emoji = ":chains:" - - # Format as Kind/Namespace/Name - if subject_namespace: - resource_text = f"{subject_kind}/{subject_namespace}/{subject_name}" - else: - resource_text = f"{subject_kind}/{subject_name}" - - # Prepare template context - template_context = { - "title": title, - "status_text": status_text, - "status_emoji": status_emoji, - "severity": sev.name.capitalize(), - "severity_emoji": sev.to_emoji(), - "alert_type": alert_type, - "cluster_name": self.cluster_name, - "platform_enabled": platform_enabled, - "include_investigate_link": include_investigate_link, - "investigate_uri": investigate_uri, - "resource_text": resource_text, - "resource_emoji": resource_emoji, - "finding": finding.to_json() if hasattr(finding, "to_json") else {} - } - - # If custom template provided, use it directly with Jinja - if custom_template: - try: - template = Template(custom_template) - rendered_blocks = [] - for block_str in template.render(**template_context).strip().split("\n\n"): - if block_str.strip(): - block = json.loads(block_str) - rendered_blocks.append(block) - return rendered_blocks - except Exception as e: - logging.error(f"Error rendering custom template: {e}") - # Fall back to file-based template - - # Use file-based template - return template_loader.render_to_blocks("header.j2", template_context) - - # Fallback to hard-coded blocks if Jinja2 is not available - # Create the title with status and name prominently displayed as per user feedback + # Prepare data for template status_text = "Firing" if status == FindingStatus.FIRING else "Resolved" status_emoji = "āš ļø" if status == FindingStatus.FIRING else "āœ…" + investigate_uri = finding.get_investigate_uri(self.account_id, self.cluster_name) if platform_enabled else "" - # Format: Status emoji [Status] AlertName - if platform_enabled and include_investigate_link: - title_text = f"{status_emoji} *[{status_text}] <{finding.get_investigate_uri(self.account_id, self.cluster_name)}|{title}>*" - else: - title_text = f"{status_emoji} *[{status_text}] {title}*" - - title_block = { - "type": "section", - "text": { - "type": "mrkdwn", - "text": title_text - } - } - - # Create metadata line with "cluster" instead of "source" - cluster_text = f"Cluster: {self.cluster_name}" - - # Get type information separately + # Get alert type information if finding.source == FindingSource.PROMETHEUS: alert_type = "Alert" elif finding.source == FindingSource.KUBERNETES_API_SERVER: @@ -495,26 +394,11 @@ def __create_finding_header( else: alert_type = "Notification" - # Create a context block with metadata but without duplicating status info - context_elements = [ - { - "type": "mrkdwn", - "text": f":bell: Type: {alert_type}" - }, - { - "type": "mrkdwn", - "text": f"{sev.to_emoji()} Severity: {sev.name.capitalize()}" - }, - { - "type": "mrkdwn", - "text": f":globe_with_meridians: {cluster_text}" - } - ] + # Prepare resource text and emoji if available + resource_text = "" + resource_emoji = ":package:" - # Always show resource kind, namespace and name if available if finding.subject: - # Add subject kind, namespace and name as a single piece of information - kind_emoji = ":package:" subject_kind = finding.subject.subject_type.value subject_namespace = finding.subject.namespace subject_name = finding.subject.name @@ -522,35 +406,57 @@ def __create_finding_header( if subject_kind and subject_name: # Choose emoji based on kind if subject_kind.lower() == "pod": - kind_emoji = ":ship:" + resource_emoji = ":ship:" elif subject_kind.lower() == "deployment": - kind_emoji = ":package:" + resource_emoji = ":package:" elif subject_kind.lower() == "node": - kind_emoji = ":computer:" + resource_emoji = ":computer:" elif subject_kind.lower() == "service": - kind_emoji = ":link:" + resource_emoji = ":link:" elif subject_kind.lower() == "job": - kind_emoji = ":clock1:" + resource_emoji = ":clock1:" elif subject_kind.lower() == "statefulset": - kind_emoji = ":chains:" + resource_emoji = ":chains:" # Format as Kind/Namespace/Name if subject_namespace: - subject_text = f"{kind_emoji} Resource: {subject_kind}/{subject_namespace}/{subject_name}" + resource_text = f"{subject_kind}/{subject_namespace}/{subject_name}" else: - subject_text = f"{kind_emoji} Resource: {subject_kind}/{subject_name}" - - context_elements.append({ - "type": "mrkdwn", - "text": subject_text - }) - - context_block = { - "type": "context", - "elements": context_elements + resource_text = f"{subject_kind}/{subject_name}" + + # Prepare template context + template_context = { + "title": title, + "status_text": status_text, + "status_emoji": status_emoji, + "severity": sev.name.capitalize(), + "severity_emoji": sev.to_emoji(), + "alert_type": alert_type, + "cluster_name": self.cluster_name, + "platform_enabled": platform_enabled, + "include_investigate_link": include_investigate_link, + "investigate_uri": investigate_uri, + "resource_text": resource_text, + "resource_emoji": resource_emoji, + "finding": finding.to_json() if hasattr(finding, "to_json") else {} } - return [title_block, context_block] + # If custom template provided, use it directly with Jinja + if custom_template: + try: + template = Template(custom_template) + rendered_blocks = [] + for block_str in template.render(**template_context).strip().split("\n\n"): + if block_str.strip(): + block = json.loads(block_str) + rendered_blocks.append(block) + return rendered_blocks + except Exception as e: + logging.error(f"Error rendering custom template: {e}") + # Fall back to file-based template + + # Use file-based template + return template_loader.render_to_blocks("header.j2", template_context) def __create_links( self, diff --git a/test_slack_integration.py b/test_slack_integration.py index 426c37d34..a9507b172 100755 --- a/test_slack_integration.py +++ b/test_slack_integration.py @@ -15,7 +15,7 @@ # Add the src directory to the Python path so we can import the modules sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) -from robusta.integrations.slack.sender import SlackSender, JINJA2_AVAILABLE +from robusta.integrations.slack.sender import SlackSender from robusta.core.reporting.base import Finding, FindingSource, FindingSeverity, FindingStatus, FindingSubject, Link, EnrichmentType from robusta.core.reporting.consts import FindingSubjectType, FindingType from robusta.core.reporting.base import LinkType @@ -416,13 +416,6 @@ def main(): if not SLACK_TOKEN: logging.error("SLACK_TOKEN environment variable not set. Please set it to proceed.") sys.exit(1) - - # Check if Jinja2 is available - if JINJA2_AVAILABLE: - logging.info("Jinja2 is available, using templated headers") - else: - logging.warning("Jinja2 is not available! Using fallback header format.") - logging.warning("To use templates, install Jinja2: pip install jinja2>=3.1.5") # Enable detailed block logging if DEBUG=true if os.environ.get("DEBUG", "").lower() == "true": From 9b15ca12072c2583fe593cfdb8a96761c9c3d39e Mon Sep 17 00:00:00 2001 From: Robusta Runner Date: Mon, 21 Apr 2025 23:10:40 +0300 Subject: [PATCH 15/42] add legacy option --- docs/configuration/sinks/slack.rst | 96 +++++++++++++++++++ .../core/sinks/slack/slack_sink_params.py | 5 +- .../core/sinks/slack/templates/legacy.j2 | 31 ++++++ src/robusta/integrations/slack/sender.py | 13 ++- test_slack_integration.py | 18 +++- 5 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 src/robusta/core/sinks/slack/templates/legacy.j2 diff --git a/docs/configuration/sinks/slack.rst b/docs/configuration/sinks/slack.rst index 84e326e0c..caa9746c7 100644 --- a/docs/configuration/sinks/slack.rst +++ b/docs/configuration/sinks/slack.rst @@ -255,4 +255,100 @@ Available template variables: Currently available templates: +* ``header.j2`` - The header section of alert notificationsTemplate Styles and Customization +------------------------------------------------------------------- + +Slack messages in Robusta can be customized using different template styles and Jinja2 templates. + +Template Styles +~~~~~~~~~~~~~~ + +Robusta supports two built-in template styles: + +1. **default** - Modern JIRA-style formatting (default) +2. **legacy** - Classic formatting matching Robusta's original style + +To select a template style: + +.. code-block:: yaml + + sinksConfig: + - slack_sink: + name: main_slack_sink + slack_channel: "#alerts" + api_key: xoxb-112... + template_style: "legacy" # Use "default" or "legacy" + +Custom Templates +~~~~~~~~~~~~~~~ + +For complete control over message formatting, you can provide custom Jinja2 templates: + +.. code-block:: yaml + + sinksConfig: + - slack_sink: + name: main_slack_sink + slack_channel: "#alerts" + api_key: xoxb-112... + custom_templates: + header.j2: | + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{{ status_emoji }} *CUSTOM ALERT: {{ title }}*" + } + } + + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": ":bell: {{ alert_type }} on cluster {{ cluster_name }}" + }, + { + "type": "mrkdwn", + "text": "{{ severity_emoji }} {{ severity }}" + } + ] + } + +Templates use Slack's Block Kit format and must generate valid JSON. Each template block is separated by double newlines (``\n\n``). + +Available template variables: + ++-----------------------------+-------------------------------------------------------------+ +| Variable | Description | ++=============================+=============================================================+ +| ``title`` | The alert title | ++-----------------------------+-------------------------------------------------------------+ +| ``status_text`` | "Firing" or "Resolved" | ++-----------------------------+-------------------------------------------------------------+ +| ``status_emoji`` | "āš ļø" (for firing) or "āœ…" (for resolved) | ++-----------------------------+-------------------------------------------------------------+ +| ``severity`` | Alert severity (e.g., "Warning", "Critical") | ++-----------------------------+-------------------------------------------------------------+ +| ``severity_emoji`` | Emoji for the severity level | ++-----------------------------+-------------------------------------------------------------+ +| ``alert_type`` | "Alert", "K8s Event", or "Notification" | ++-----------------------------+-------------------------------------------------------------+ +| ``cluster_name`` | The name of the cluster | ++-----------------------------+-------------------------------------------------------------+ +| ``platform_enabled`` | Boolean indicating if Robusta platform is enabled | ++-----------------------------+-------------------------------------------------------------+ +| ``include_investigate_link``| Boolean for including investigate link | ++-----------------------------+-------------------------------------------------------------+ +| ``investigate_uri`` | URI for investigation | ++-----------------------------+-------------------------------------------------------------+ +| ``resource_text`` | Resource identifier (e.g., "Pod/namespace/name") | ++-----------------------------+-------------------------------------------------------------+ +| ``resource_emoji`` | Emoji for the resource type | ++-----------------------------+-------------------------------------------------------------+ +| ``finding`` | The complete finding object with all alert data | ++-----------------------------+-------------------------------------------------------------+ + +Currently available templates: + * ``header.j2`` - The header section of alert notifications \ No newline at end of file diff --git a/src/robusta/core/sinks/slack/slack_sink_params.py b/src/robusta/core/sinks/slack/slack_sink_params.py index 36e19a849..6eccf9007 100644 --- a/src/robusta/core/sinks/slack/slack_sink_params.py +++ b/src/robusta/core/sinks/slack/slack_sink_params.py @@ -2,7 +2,7 @@ from robusta.core.sinks.sink_config import SinkConfigBase from robusta.core.sinks.common import ChannelTransformer -from typing import Optional, Dict +from typing import Optional, Dict, Literal from pydantic import validator @@ -15,7 +15,8 @@ class SlackSinkParams(SinkBaseParams): send_svg: bool = True prefer_redirect_to_platform: bool = True - # Template customization options + # Template selection and customization options + template_style: Literal["default", "legacy"] = "default" # Use "legacy" for old-style formatting custom_templates: Optional[Dict[str, str]] = None # Template name -> custom template content @classmethod diff --git a/src/robusta/core/sinks/slack/templates/legacy.j2 b/src/robusta/core/sinks/slack/templates/legacy.j2 new file mode 100644 index 000000000..f1b3831a0 --- /dev/null +++ b/src/robusta/core/sinks/slack/templates/legacy.j2 @@ -0,0 +1,31 @@ +{# Legacy template that mimics the original Slack message format from before templates #} +{# This template produces messages that look identical to pre-template Robusta #} + +{# First create the status/header block - different format based on source #} +{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{% if finding.source == 'PROMETHEUS' %}{% if status_text == 'Firing' %}{{ status_emoji }} `Prometheus Alert Firing` {{ status_emoji }}{% else %}{{ status_emoji }} *Prometheus resolved*{% endif %}{% elif finding.source == 'KUBERNETES_API_SERVER' %}šŸ‘€ *K8s event detected*{% else %}šŸ‘€ *Notification*{% endif %} {{ severity_emoji }} *{{ severity }}*\n{% if platform_enabled and include_investigate_link %}<{{ investigate_uri }}|*{{ title }}*>{% else %}*{{ title }}*{% endif %}" + } +} + +{# Source block - old format used "Source" instead of "Cluster" #} +{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Source:* `{{ cluster_name }}`" + } +} + +{# Description block if available - formatted based on source #} +{% if finding.description %} +{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{% if finding.source == 'PROMETHEUS' %}šŸ”” *Alert:* {{ finding.description }}{% elif finding.source == 'KUBERNETES_API_SERVER' %}šŸ‘€ *K8s event detected:* {{ finding.description }}{% else %}šŸ‘€ *Notification:* {{ finding.description }}{% endif %}" + } +} +{% endif %} \ No newline at end of file diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index da5b0dff1..b6ae87fd6 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -376,10 +376,15 @@ def __create_finding_header( title = finding.title.removeprefix("[RESOLVED] ") sev = finding.severity + # Select appropriate template based on user preference + template_name = "header.j2" # default template + if sink_params and hasattr(sink_params, "template_style") and sink_params.template_style == "legacy": + template_name = "legacy.j2" + # Check if the user has provided a custom template custom_template = None - if sink_params and sink_params.custom_templates and "header.j2" in sink_params.custom_templates: - custom_template = sink_params.custom_templates["header.j2"] + if sink_params and sink_params.custom_templates and template_name in sink_params.custom_templates: + custom_template = sink_params.custom_templates[template_name] # Prepare data for template status_text = "Firing" if status == FindingStatus.FIRING else "Resolved" @@ -438,7 +443,7 @@ def __create_finding_header( "investigate_uri": investigate_uri, "resource_text": resource_text, "resource_emoji": resource_emoji, - "finding": finding.to_json() if hasattr(finding, "to_json") else {} + "finding": finding } # If custom template provided, use it directly with Jinja @@ -456,7 +461,7 @@ def __create_finding_header( # Fall back to file-based template # Use file-based template - return template_loader.render_to_blocks("header.j2", template_context) + return template_loader.render_to_blocks(template_name, template_context) def __create_links( self, diff --git a/test_slack_integration.py b/test_slack_integration.py index a9507b172..9f4f84a14 100755 --- a/test_slack_integration.py +++ b/test_slack_integration.py @@ -441,6 +441,17 @@ def main(): max_log_file_limit_kb=1000 ) + # Create a SlackSinkParams with legacy style + legacy_template_params = SlackSinkParams( + name="legacy-template-sink", + slack_channel=SLACK_CHANNEL, + api_key=SLACK_TOKEN, + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000, + template_style="legacy" # Use the legacy template style + ) + # Create a SlackSinkParams with a custom template custom_template_params = SlackSinkParams( name="custom-template-sink", @@ -489,11 +500,12 @@ def main(): cpu_throttling_finding = create_cpu_throttling_finding() # Send findings to Slack with standard templates - logging.info(f"Sending crash loop finding to Slack channel: {SLACK_CHANNEL}...") + logging.info(f"Sending crash loop finding to Slack channel (DEFAULT TEMPLATE): {SLACK_CHANNEL}...") sender.send_finding_to_slack(crash_loop_finding, sink_params, platform_enabled=True) - logging.info(f"Sending node saturation finding to Slack channel: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(node_saturation_finding, sink_params, platform_enabled=True) + # Send findings to Slack with legacy template style + logging.info(f"Sending node saturation finding to Slack channel with LEGACY TEMPLATE: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(node_saturation_finding, legacy_template_params, platform_enabled=True) # Send findings to Slack with custom templates logging.info(f"Sending datadog agent crash finding to Slack channel with CUSTOM TEMPLATE: {SLACK_CHANNEL}...") From 92816481ce4c8f10ca4e4409e85e0e159d6e637e Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Wed, 14 May 2025 11:47:47 +0300 Subject: [PATCH 16/42] bugfix sending to slack --- src/robusta/integrations/slack/sender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 0bcb74a9e..1820a3afb 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -731,7 +731,7 @@ def send_finding_to_slack( file_blocks.extend(Transformer.tableblock_to_fileblocks(other_blocks, SLACK_TABLE_COLUMNS_LIMIT)) file_blocks.extend(Transformer.tableblock_to_fileblocks(attachment_blocks, SLACK_TABLE_COLUMNS_LIMIT)) - message = self.prepare_slack_text( + message, error_msg = self.prepare_slack_text( finding.title, max_log_file_limit_kb=sink_params.max_log_file_limit_kb, files=file_blocks ) From 92c1b76d531a60f537968e0525dc7f6330625add Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Wed, 14 May 2025 13:21:10 +0300 Subject: [PATCH 17/42] fixing template --- .../sinks/slack/templates/template_loader.py | 76 ++++++++++++++----- src/robusta/integrations/slack/sender.py | 22 ++---- 2 files changed, 64 insertions(+), 34 deletions(-) diff --git a/src/robusta/core/sinks/slack/templates/template_loader.py b/src/robusta/core/sinks/slack/templates/template_loader.py index 64b561792..93fe75d26 100644 --- a/src/robusta/core/sinks/slack/templates/template_loader.py +++ b/src/robusta/core/sinks/slack/templates/template_loader.py @@ -1,7 +1,8 @@ import json import logging import os -from typing import Dict, Any, Optional, List, Tuple +import re +from typing import Any, Dict, List from jinja2 import Environment, FileSystemLoader, Template, select_autoescape @@ -9,6 +10,15 @@ TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__))) +def escape_raw_newlines_in_json_strings(raw_json: str) -> str: + def fix_string(match): + s = match.group(1) + return s.replace("\n", "\\n") # Escape raw newlines in strings + + STRING_LITERAL_RE = re.compile(r'("(?:\\.|[^"\\])*")', flags=re.DOTALL) + return STRING_LITERAL_RE.sub(fix_string, raw_json) + + class SlackTemplateLoader: """ Loads and renders Jinja2 templates for Slack messages. @@ -28,10 +38,10 @@ def __init__(self): def get_template(self, template_name: str) -> Template: """ Get a template by name, loading from file if not already cached. - + Args: template_name: The name of the template file (e.g., "header.j2") - + Returns: A Jinja2 Template object """ @@ -42,41 +52,73 @@ def get_template(self, template_name: str) -> Template: logging.error(f"Error loading template {template_name}: {e}") # Return a simple default template as fallback return Template("Template loading error") - + return self._templates[template_name] def render_to_blocks(self, template_name: str, context: Dict[str, Any]) -> List[Dict[str, Any]]: """ Render a template using the provided context and parse the result as JSON to get Slack blocks. - + Args: template_name: The name of the template file context: Dictionary of variables to pass to the template - + Returns: List of Slack block objects (dictionaries) """ template = self.get_template(template_name) - + try: rendered = template.render(**context) - + # Split by newlines to get multiple blocks and parse each as JSON blocks = [] + blocks = [] for block_str in rendered.strip().split("\n\n"): - if block_str.strip(): - try: - block = json.loads(block_str) - blocks.append(block) - except json.JSONDecodeError as e: - logging.error(f"Error parsing JSON from template output: {e}") - logging.debug(f"Problematic JSON: {block_str}") - + if not block_str.strip(): + continue + + try: + block_str_fixed = escape_raw_newlines_in_json_strings(block_str) + block = json.loads(block_str_fixed) + blocks.append(block) + + except json.JSONDecodeError as e: + logging.exception(f"Error parsing JSON from template output: {e}") + logging.warning(f"Problematic JSON (repr): {repr(block_str)}") + return blocks except Exception as e: logging.error(f"Error rendering template {template_name}: {e}") return [] + def render_custom_or_file_template_to_blocks(self, template_name: str, context: Dict[str, Any], custom_template: str = None) -> List[Dict[str, Any]]: + """ + Render a custom Jinja template string (if provided) or a file-based template to Slack blocks. + Args: + template_name: The name of the file-based template (e.g., "header.j2") + context: Dictionary of variables to pass to the template + custom_template: Optional Jinja template string to use instead of file-based template + Returns: + List of Slack block objects (dictionaries) + """ + if custom_template: + try: + template = Template(custom_template) + rendered_blocks = [] + for block_str in template.render(**context).strip().split("\n\n"): + if block_str.strip(): + block_str_fixed = escape_raw_newlines_in_json_strings(block_str) + block = json.loads(block_str_fixed) + rendered_blocks.append(block) + return rendered_blocks + except Exception as e: + logging.error(f"Error rendering custom template: {e}") + # Fall back to file-based template + + # Use file-based template + return self.render_to_blocks(template_name, context) + # Singleton instance -template_loader = SlackTemplateLoader() \ No newline at end of file +template_loader = SlackTemplateLoader() diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 1820a3afb..5e0b31409 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -507,23 +507,11 @@ def __create_finding_header( "resource_emoji": resource_emoji, "finding": finding } - - # If custom template provided, use it directly with Jinja - if custom_template: - try: - template = Template(custom_template) - rendered_blocks = [] - for block_str in template.render(**template_context).strip().split("\n\n"): - if block_str.strip(): - block = json.loads(block_str) - rendered_blocks.append(block) - return rendered_blocks - except Exception as e: - logging.error(f"Error rendering custom template: {e}") - # Fall back to file-based template - - # Use file-based template - return template_loader.render_to_blocks(template_name, template_context) + + # Use the new template loader method for both custom and file-based templates + return template_loader.render_custom_or_file_template_to_blocks( + template_name, template_context, custom_template + ) def __create_links( self, From 4e6b055b292d64f2d999eab01030e41b0cb2a2b4 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Wed, 14 May 2025 14:25:37 +0300 Subject: [PATCH 18/42] refactoring code --- .../core/sinks/slack/slack_sink_params.py | 37 +- .../sinks/slack/templates/template_loader.py | 39 +- src/robusta/integrations/slack/sender.py | 26 +- test_slack_integration.py | 554 ------------------ tests/test_slack_integration.py | 79 +++ 5 files changed, 141 insertions(+), 594 deletions(-) delete mode 100755 test_slack_integration.py create mode 100755 tests/test_slack_integration.py diff --git a/src/robusta/core/sinks/slack/slack_sink_params.py b/src/robusta/core/sinks/slack/slack_sink_params.py index 6eccf9007..767f532e3 100644 --- a/src/robusta/core/sinks/slack/slack_sink_params.py +++ b/src/robusta/core/sinks/slack/slack_sink_params.py @@ -1,23 +1,46 @@ from robusta.core.sinks.sink_base_params import SinkBaseParams from robusta.core.sinks.sink_config import SinkConfigBase from robusta.core.sinks.common import ChannelTransformer - +from enum import Enum from typing import Optional, Dict, Literal from pydantic import validator +class SlackTemplateStyle(str, Enum): + DEFAULT = "default" + LEGACY = "legacy" + + class SlackSinkParams(SinkBaseParams): slack_channel: str api_key: str channel_override: Optional[str] = None max_log_file_limit_kb: int = 1000 investigate_link: bool = True - send_svg: bool = True - prefer_redirect_to_platform: bool = True - - # Template selection and customization options - template_style: Literal["default", "legacy"] = "default" # Use "legacy" for old-style formatting - custom_templates: Optional[Dict[str, str]] = None # Template name -> custom template content + + template_style: SlackTemplateStyle = SlackTemplateStyle.DEFAULT # Use "legacy" for old-style formatting + slack_custom_templates: Optional[Dict[str, str]] = None # Template name -> custom template content + template_name: Optional[str] = None + + def get_effective_template_name(self) -> str: + """ + Returns the template name to use for this sink. If template_name is set, use it. + Otherwise, use 'legacy.j2' if template_style is legacy, else 'header.j2'. + """ + if self.template_name: + return self.template_name + if self.template_style == SlackTemplateStyle.LEGACY: + return "legacy.j2" + return "header.j2" + + def get_custom_template(self) -> Optional[str]: + """ + Returns the custom template string for the effective template name, if it exists. + """ + template_name = self.get_effective_template_name() + if self.slack_custom_templates and template_name in self.slack_custom_templates: + return self.slack_custom_templates[template_name] + return None @classmethod def _supports_grouping(cls): diff --git a/src/robusta/core/sinks/slack/templates/template_loader.py b/src/robusta/core/sinks/slack/templates/template_loader.py index 93fe75d26..6ec227b37 100644 --- a/src/robusta/core/sinks/slack/templates/template_loader.py +++ b/src/robusta/core/sinks/slack/templates/template_loader.py @@ -92,32 +92,33 @@ def render_to_blocks(self, template_name: str, context: Dict[str, Any]) -> List[ logging.error(f"Error rendering template {template_name}: {e}") return [] - def render_custom_or_file_template_to_blocks(self, template_name: str, context: Dict[str, Any], custom_template: str = None) -> List[Dict[str, Any]]: + def render_custom_template_to_blocks(self, custom_template: str, context: Dict[str, Any]) -> List[Dict[str, Any]]: """ - Render a custom Jinja template string (if provided) or a file-based template to Slack blocks. + Render a custom Jinja template string to Slack blocks. Args: - template_name: The name of the file-based template (e.g., "header.j2") + custom_template: Jinja template string context: Dictionary of variables to pass to the template - custom_template: Optional Jinja template string to use instead of file-based template Returns: List of Slack block objects (dictionaries) """ - if custom_template: - try: - template = Template(custom_template) - rendered_blocks = [] - for block_str in template.render(**context).strip().split("\n\n"): - if block_str.strip(): - block_str_fixed = escape_raw_newlines_in_json_strings(block_str) - block = json.loads(block_str_fixed) - rendered_blocks.append(block) - return rendered_blocks - except Exception as e: - logging.error(f"Error rendering custom template: {e}") - # Fall back to file-based template + try: + template = Template(custom_template) + return self.render_to_blocks(template, context) + except Exception as e: + logging.error(f"Error rendering custom template: {e}") + return [] - # Use file-based template - return self.render_to_blocks(template_name, context) + def render_file_template_to_blocks(self, template_name: str, context: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Render a file-based Jinja template to Slack blocks. + Args: + template_name: The name of the file-based template (e.g., "header.j2") + context: Dictionary of variables to pass to the template + Returns: + List of Slack block objects (dictionaries) + """ + template = self.get_template(template_name) + return self.render_to_blocks(template, context) # Singleton instance diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 5e0b31409..512f1e525 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -437,17 +437,8 @@ def __create_finding_header( title, mention = self.extract_mentions(title) sev = finding.severity + - # Select appropriate template based on user preference - template_name = "header.j2" # default template - if sink_params and hasattr(sink_params, "template_style") and sink_params.template_style == "legacy": - template_name = "legacy.j2" - - # Check if the user has provided a custom template - custom_template = None - if sink_params and sink_params.custom_templates and template_name in sink_params.custom_templates: - custom_template = sink_params.custom_templates[template_name] - # Prepare data for template status_text = "Firing" if status == FindingStatus.FIRING else "Resolved" status_emoji = "āš ļø" if status == FindingStatus.FIRING else "āœ…" @@ -508,10 +499,17 @@ def __create_finding_header( "finding": finding } - # Use the new template loader method for both custom and file-based templates - return template_loader.render_custom_or_file_template_to_blocks( - template_name, template_context, custom_template - ) + # Determine the template name to use + template_name = sink_params.get_effective_template_name() if sink_params else "header.j2" + + # Get the custom template for this template name, if any + custom_template = sink_params.get_custom_template() if sink_params else None + + # Use the new template loader methods for custom or file-based templates + if custom_template: + return template_loader.render_custom_template_to_blocks(custom_template, template_context) + else: + return template_loader.render_file_template_to_blocks(template_name, template_context) def __create_links( self, diff --git a/test_slack_integration.py b/test_slack_integration.py deleted file mode 100755 index 9f4f84a14..000000000 --- a/test_slack_integration.py +++ /dev/null @@ -1,554 +0,0 @@ -#!/usr/bin/env python3 -""" -This script is a test harness for the Robusta Slack integration. -It allows you to quickly iterate on Slack message formatting by sending -test messages with mock data. -""" - -import os -import sys -import logging -import json -from typing import List, Dict, Any, Optional -from datetime import datetime - -# Add the src directory to the Python path so we can import the modules -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) - -from robusta.integrations.slack.sender import SlackSender -from robusta.core.reporting.base import Finding, FindingSource, FindingSeverity, FindingStatus, FindingSubject, Link, EnrichmentType -from robusta.core.reporting.consts import FindingSubjectType, FindingType -from robusta.core.reporting.base import LinkType -from robusta.core.reporting.blocks import ( - MarkdownBlock, - HeaderBlock, - DividerBlock, - TableBlock, - ListBlock, - LinkProp, - LinksBlock, - CallbackBlock, - CallbackChoice, - FileBlock -) -from robusta.core.model.base_params import ResourceInfo -from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams -from robusta.core.playbooks.internal.ai_integration import ask_holmes - -# Configure logging - set to DEBUG to see full block details -logging.basicConfig( - level=logging.DEBUG if os.environ.get("DEBUG", "").lower() == "true" else logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" -) - -# You'll need to provide a Slack API token to run this script -# You can get one from https://api.slack.com/apps -# For testing, you can hardcode a token here, but don't commit it to the repo -# By default, try to get token from env var -SLACK_TOKEN = os.environ.get("SLACK_TOKEN", "xoxb-your-actual-token-here") -# The Slack channel to send messages to -SLACK_CHANNEL = os.environ.get("SLACK_CHANNEL", "test-robusta") - -# In a real scenario, we'd use @action decorator, but for testing -# we'll just remove the CallbackBlock as it requires the full Robusta action system -# We'll use a simple MarkdownBlock with button simulation instead - -# Create mock data for the Finding -def create_test_finding(title: str, severity: FindingSeverity = FindingSeverity.INFO) -> Finding: - """Create a test finding with the given title and severity""" - # Create a subject first with the correct type - subject = FindingSubject( - name="test-pod", - namespace="default", - subject_type=FindingSubjectType.TYPE_POD, - labels={ - "app": "test-app", - "environment": "test" - }, - annotations={ - "description": "Test annotation" - } - ) - - finding = Finding( - title=title, - source=FindingSource.PROMETHEUS, - severity=severity, - aggregation_key=f"test-{title}", - finding_type=FindingType.ISSUE, - description="This is a test finding for Slack message formatting", - subject=subject - ) - - # We need to mock the service since TopServiceResolver won't work in test environment - # Create a simple mock for the service object - class MockService: - def __init__(self, name, namespace, resource_type): - self.name = name - self.namespace = namespace - self.resource_type = resource_type - - def get_resource_key(self): - return f"{self.namespace}/{self.name}" - - # Set the service directly - finding.service = MockService( - name="test-deployment", - namespace="default", - resource_type="Deployment" - ) - - # Add links as proper Link objects - - finding.links = [ - Link(url="https://example.com/logs", name="View Logs"), - Link(url="https://example.com/grafana", name="Grafana", type=LinkType.PROMETHEUS_GENERATOR_URL) - ] - - return finding - -def add_basic_blocks(finding: Finding) -> None: - """Add some basic blocks to the finding""" - finding.add_enrichment([ - HeaderBlock("Kubernetes Resource Information"), - MarkdownBlock("This alert was triggered by high resource usage"), - TableBlock( - rows=[ - ["Namespace", "default"], - ["Pod", "test-pod"], - ["Container", "test-container"], - ["CPU Usage", "250m"], - ["Memory Usage", "512Mi"], - ], - headers=["Property", "Value"], - table_name="Resource Usage", - ), - DividerBlock(), - MarkdownBlock("*Recent Events:*"), - ListBlock([ - "Pod started at 08:15:30", - "CPU usage spiked at 08:45:22", - "Memory usage increased at 08:47:15" - ]), - LinksBlock(links=[ - LinkProp(text="View Pod Details", url="https://example.com/pod"), - LinkProp(text="View Container Logs", url="https://example.com/logs") - ]), - # Instead of using CallbackBlock which requires the @action decorator - MarkdownBlock("*Available Actions:* (Simulated buttons)\n• Restart Pod\n• Scale Deployment") - ]) - -def add_complex_table(finding: Finding) -> None: - """Add a more complex table to the finding""" - finding.add_enrichment([ - HeaderBlock("Detailed Resource Metrics"), - TableBlock( - rows=[ - ["pod-1", "Running", "0.25", "256Mi", "node-1", "app=web,tier=frontend"], - ["pod-2", "Running", "0.50", "512Mi", "node-1", "app=web,tier=frontend"], - ["pod-3", "CrashLoopBackOff", "0.10", "128Mi", "node-2", "app=db,tier=backend"], - ["pod-4", "Running", "0.75", "1024Mi", "node-2", "app=api,tier=backend"], - ["pod-5", "Pending", "0", "0", "None", "app=cache,tier=backend"], - ], - headers=["Pod", "Status", "CPU", "Memory", "Node", "Labels"], - table_name="Cluster Pod Overview", - ) - ]) - -def create_holmes_callback(finding: Finding) -> None: - """Add a Holmes AI callback to the finding""" - # Instead of using a real callback, we'll simulate it with markdown - finding.add_enrichment([ - MarkdownBlock("*AI Investigation:*"), - MarkdownBlock("šŸ¤– *Ask Holmes AI* to investigate this alert (simulated button)"), - MarkdownBlock("_Holmes would analyze this alert and provide insights on cause and remediation._") - ]) - -def create_crash_loop_finding() -> Finding: - """Create a sample crash loop finding with exact requested data""" - subject = FindingSubject( - name="payment-processing-worker-747ccfb9db-r7z7m", - namespace="default", - subject_type=FindingSubjectType.TYPE_POD, - node="gke-1-4d3c8387-d24h", - labels={ - "app": "payment-processing-worker", - "pod-template-hash": "747ccfb9db" - }, - annotations={} - ) - - finding = Finding( - title="Crashing pod payment-processing-worker-747ccfb9db-r7z7m in namespace default", - source=FindingSource.KUBERNETES_API_SERVER, - severity=FindingSeverity.HIGH, - aggregation_key="CrashLoopBackoff", - finding_type=FindingType.ISSUE, - subject=subject, - failure=True, - fingerprint="d7c6e2ac798ee28c8a4ac6c8c33de52e60f0b019cd10d78ac04831d44cf86389", - starts_at=datetime(2025, 3, 23, 20, 8, 43, 822328) - ) - - # Add the crash info enrichment - finding.add_enrichment([ - TableBlock( - rows=[ - ["Container", "payment-processing-container"], - ["Restarts", 130], - ["Status", "WAITING"], - ["Reason", "CrashLoopBackOff"] - ], - headers=["label", "value"], - table_name="*Crash Info*", - metadata={"format": "vertical"} - ), - TableBlock( - rows=[ - ["Status", "TERMINATED"], - ["Reason", "Completed"], - ["Started at", "2025-03-23T18:08:32Z"], - ["Finished at", "2025-03-23T18:08:32Z"] - ], - headers=["label", "value"], - table_name="*Previous Container*", - metadata={"format": "vertical"} - ) - ], title="Container Crash information", enrichment_type=EnrichmentType.crash_info) - - # Add the logs enrichment - finding.add_enrichment([ - FileBlock( - filename="payment-processing-worker-747ccfb9db-r7z7m.log", - contents=b'Environment variable DEPLOY_ENV is undefined\n' - ) - ], title="Logs", enrichment_type=EnrichmentType.text_file) - - return finding - -def create_node_saturation_finding() -> Finding: - """Create a node saturation finding with exact requested data""" - subject = FindingSubject( - name="gke-1-4d3c8387-c4bp", - subject_type=FindingSubjectType.TYPE_NODE, - namespace="default", - node="gke-1-4d3c8387-c4bp", - container="node-exporter", - labels={ - "beta.kubernetes.io/arch": "amd64", - "beta.kubernetes.io/instance-type": "e2-standard-2", - "beta.kubernetes.io/os": "linux", - "cloud.google.com/gke-boot-disk": "pd-balanced", - "cloud.google.com/gke-container-runtime": "containerd", - "cloud.google.com/gke-cpu-scaling-level": "2", - "cloud.google.com/gke-logging-variant": "DEFAULT", - "cloud.google.com/gke-max-pods-per-node": "110", - "cloud.google.com/gke-memory-gb-scaling-level": "8", - "cloud.google.com/gke-nodepool": "pool-1", - "cloud.google.com/gke-os-distribution": "cos", - "cloud.google.com/gke-provisioning": "spot", - "cloud.google.com/gke-spot": "true", - "cloud.google.com/gke-stack-type": "IPV4", - "cloud.google.com/machine-family": "e2", - "cloud.google.com/private-node": "false", - "failure-domain.beta.kubernetes.io/region": "us-central1", - "failure-domain.beta.kubernetes.io/zone": "us-central1-c", - "kubernetes.io/arch": "amd64", - "kubernetes.io/hostname": "gke-4d3c8387-c4bp" - }, - annotations={} - ) - - finding = Finding( - title="System saturated, load per core is very high.", - source=FindingSource.PROMETHEUS, - severity=FindingSeverity.LOW, - aggregation_key="NodeSystemSaturation", - finding_type=FindingType.ISSUE, - subject=subject, - description="System load per core at 10.128.0.77:9104 has been above 2 for the last 15 minutes, is currently at 5.22.\nThis might indicate this instance resources saturation and can cause it becoming unresponsive.\n", - starts_at=datetime(2025, 3, 23, 20, 9, 4, 838000) - ) - - # Could add enrichments here if needed - - return finding - -def create_datadog_agent_crash_finding() -> Finding: - """Create a datadog agent crash finding""" - subject = FindingSubject( - name="datadog-agent-w49mv", - subject_type=FindingSubjectType.TYPE_POD, - namespace="default", - node="gke-1-4d3c8387-mm23", - labels={ - "agent.datadoghq.com/component": "agent", - "agent.datadoghq.com/name": "datadog", - "agent.datadoghq.com/provider": "", - "app.kubernetes.io/component": "agent", - "app.kubernetes.io/instance": "datadog-agent", - "app.kubernetes.io/managed-by": "datadog-operator", - "app.kubernetes.io/name": "datadog-agent-deployment", - "app.kubernetes.io/part-of": "default-datadog" - }, - annotations={} - ) - - finding = Finding( - title="Crashing pod datadog-agent-w49mv in namespace default", - source=FindingSource.KUBERNETES_API_SERVER, - severity=FindingSeverity.HIGH, - aggregation_key="CrashLoopBackoff", - finding_type=FindingType.ISSUE, - subject=subject, - failure=True, - starts_at=datetime(2025, 3, 23, 20, 9, 2, 211000) - ) - - # Could add crash info enrichment here if needed - - return finding - -def create_cpu_throttling_finding() -> Finding: - """Create a CPU throttling finding with exact requested data""" - subject = FindingSubject( - name="vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm", - subject_type=FindingSubjectType.TYPE_POD, - namespace="default", - node="gke-4ksv", - container="vmagent", - labels={ - "app.kubernetes.io/component": "monitoring", - "app.kubernetes.io/instance": "vmks-victoria-metrics-k8s-stack", - "app.kubernetes.io/name": "vmagent", - "managed-by": "vm-operator", - "pod-template-hash": "6dcd499cb5", - "alertname": "CPUThrottlingHigh", - "container": "vmagent", - "namespace": "default", - "pod": "vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm", - "prometheus": "default/robusta-kube-prometheus-st-prometheus", - "severity": "info" - }, - annotations={ - "description": "71.13% throttling of CPU in namespace default for container vmagent in pod vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm.", - "runbook_url": "https://runbooks.prometheus-operator.dev/runbooks/kubernetes/cputhrottlinghigh", - "summary": "Processes experience elevated CPU throttling." - } - ) - - # Create the finding - finding = Finding( - title="Processes experience elevated CPU throttling.", - source=FindingSource.PROMETHEUS, - severity=FindingSeverity.INFO, - aggregation_key="CPUThrottlingHigh", - finding_type=FindingType.ISSUE, - subject=subject, - description="71.13% throttling of CPU in namespace default for container vmagent in pod vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm.", - failure=True, - fingerprint="9674d957b35e0e9f", - starts_at=datetime(2025, 3, 22, 10, 22, 10, 219000), - add_silence_url=True, - silence_labels={ - "alertname": "CPUThrottlingHigh", - "container": "vmagent", - "namespace": "default", - "pod": "vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm", - "prometheus": "default/robusta-kube-prometheus-st-prometheus", - "severity": "info" - } - ) - - # Add explanation enrichment - finding.add_enrichment([ - MarkdownBlock('šŸ“˜ *Alert Explanation:* This pod is throttled due to its CPU limit. This can occur even when CPU usage is far below the limit. '), - MarkdownBlock("šŸ›  *Robusta's Recommendation:* Remove this pod's CPU limit entirely. ") - ], annotations={"unfurl": False}) - - # Instead of adding an SVG file which can cause JSON serialization issues, - # Let's add a simpler enrichment with just a table for CPU usage - finding.add_enrichment([ - TableBlock( - rows=[ - ["Current CPU Usage", "19.5m"], - ["CPU Request", "100m"], - ["CPU Limit", "300m"], - ["Throttling Percentage", "71.13%"], - ["Duration", "Last 5 minutes"] - ], - headers=["Metric", "Value"], - table_name="*CPU Usage*" - ) - ], title="Resources", enrichment_type=EnrichmentType.graph) - - # We're not including the SVG graph anymore to avoid JSON serialization issues - - # Add alert labels enrichment - finding.add_enrichment([ - TableBlock( - rows=[ - ["alertname", "CPUThrottlingHigh"], - ["container", "vmagent"], - ["namespace", "default"], - ["pod", "vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm"], - ["prometheus", "default/robusta-kube-prometheus-st-prometheus"], - ["severity", "info"] - ], - headers=["label", "value"], - table_name="*Alert labels*", - metadata={"format": "vertical"} - ) - ], title="Alert labels", enrichment_type=EnrichmentType.alert_labels, annotations={"attachment": True}) - - # Add Prometheus link - finding.add_link( - Link( - url="http://robusta-kube-prometheus-st-prometheus.default:9090/graph?g0.expr=sum+by+%28cluster%2C+container%2C+pod%2C+namespace%29+%28increase%28container_cpu_cfs_throttled_periods_total%7Bcontainer%21%3D%22%22%7D%5B5m%5D%29%29+%2F+sum+by+%28cluster%2C+container%2C+pod%2C+namespace%29+%28increase%28container_cpu_cfs_periods_total%5B5m%5D%29%29+%3E+%2825+%2F+100%29&g0.tab=1", - name="View Graph", - type=LinkType.PROMETHEUS_GENERATOR_URL - ) - ) - - return finding - -def main(): - if not SLACK_TOKEN: - logging.error("SLACK_TOKEN environment variable not set. Please set it to proceed.") - sys.exit(1) - - # Enable detailed block logging if DEBUG=true - if os.environ.get("DEBUG", "").lower() == "true": - logging.info("DEBUG mode enabled - will print detailed block information") - logging.info("Run script with DEBUG=false to disable detailed logging") - - # Initialize the SlackSender - sender = SlackSender( - slack_token=SLACK_TOKEN, - account_id="test-account", - cluster_name="test-cluster", - signing_key="test-signing-key", - slack_channel=SLACK_CHANNEL - ) - - # Create the SlackSinkParams - sink_params = SlackSinkParams( - name="test-sink", - slack_channel=SLACK_CHANNEL, - api_key=SLACK_TOKEN, - investigate_link=True, - prefer_redirect_to_platform=False, - max_log_file_limit_kb=1000 - ) - - # Create a SlackSinkParams with legacy style - legacy_template_params = SlackSinkParams( - name="legacy-template-sink", - slack_channel=SLACK_CHANNEL, - api_key=SLACK_TOKEN, - investigate_link=True, - prefer_redirect_to_platform=False, - max_log_file_limit_kb=1000, - template_style="legacy" # Use the legacy template style - ) - - # Create a SlackSinkParams with a custom template - custom_template_params = SlackSinkParams( - name="custom-template-sink", - slack_channel=SLACK_CHANNEL, - api_key=SLACK_TOKEN, - investigate_link=True, - prefer_redirect_to_platform=False, - max_log_file_limit_kb=1000, - custom_templates={ - "header.j2": """ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "{{ status_emoji }} *CUSTOM TEMPLATE: {{ title }}*" - } - } - - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": ":rotating_light: {{ alert_type }} on cluster {{ cluster_name }}" - }, - { - "type": "mrkdwn", - "text": "{{ severity_emoji }} {{ severity }} severity" - } - {% if resource_text %} - ,{ - "type": "mrkdwn", - "text": "{{ resource_emoji }} {{ resource_text }}" - } - {% endif %} - ] - } - """ - } - ) - - # Create all findings - crash_loop_finding = create_crash_loop_finding() - node_saturation_finding = create_node_saturation_finding() - datadog_agent_crash_finding = create_datadog_agent_crash_finding() - cpu_throttling_finding = create_cpu_throttling_finding() - - # Send findings to Slack with standard templates - logging.info(f"Sending crash loop finding to Slack channel (DEFAULT TEMPLATE): {SLACK_CHANNEL}...") - sender.send_finding_to_slack(crash_loop_finding, sink_params, platform_enabled=True) - - # Send findings to Slack with legacy template style - logging.info(f"Sending node saturation finding to Slack channel with LEGACY TEMPLATE: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(node_saturation_finding, legacy_template_params, platform_enabled=True) - - # Send findings to Slack with custom templates - logging.info(f"Sending datadog agent crash finding to Slack channel with CUSTOM TEMPLATE: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(datadog_agent_crash_finding, custom_template_params, platform_enabled=True) - - logging.info(f"Sending CPU throttling finding to Slack channel with CUSTOM TEMPLATE: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(cpu_throttling_finding, custom_template_params, platform_enabled=True) - - logging.info(f"Test messages sent successfully to Slack channel: {SLACK_CHANNEL}") - - # Uncomment the below code to send the original test findings - """ - # Create test findings with different severities - finding_info = create_test_finding("Test INFO Alert", FindingSeverity.INFO) - finding_low = create_test_finding("Test LOW Alert", FindingSeverity.LOW) - finding_medium = create_test_finding("Test MEDIUM Alert", FindingSeverity.MEDIUM) - finding_high = create_test_finding("Test HIGH Alert", FindingSeverity.HIGH) - - # Add different types of content to each finding - add_basic_blocks(finding_info) - add_complex_table(finding_low) - - # Combine basic and complex content for medium severity - add_basic_blocks(finding_medium) - add_complex_table(finding_medium) - - # Add AI callback for high severity - add_basic_blocks(finding_high) - create_holmes_callback(finding_high) - - # Send all test findings to Slack - logging.info(f"Sending INFO finding to Slack channel: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(finding_info, sink_params, platform_enabled=True) - - logging.info(f"Sending LOW finding to Slack channel: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(finding_low, sink_params, platform_enabled=True) - - logging.info(f"Sending MEDIUM finding to Slack channel: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(finding_medium, sink_params, platform_enabled=True) - - logging.info(f"Sending HIGH finding with Holmes callback to Slack channel: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(finding_high, sink_params, platform_enabled=True) - """ - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tests/test_slack_integration.py b/tests/test_slack_integration.py new file mode 100755 index 000000000..eb16aaebd --- /dev/null +++ b/tests/test_slack_integration.py @@ -0,0 +1,79 @@ +# This file is being moved to tests/test_slack_templates.py and refactored to match the style of test_slack.py. +# The script/main logic and unused code are removed. + +import pytest +from robusta.core.reporting.base import Finding, FindingSeverity, FindingSource +from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams, SlackTemplateStyle +from robusta.core.reporting.blocks import MarkdownBlock +from robusta.integrations.slack.sender import SlackSender +from tests.config import CONFIG +from tests.utils.slack_utils import SlackChannel + +TEST_ACCOUNT = "test account" +TEST_CLUSTER = "test cluster" +TEST_KEY = "test key" + +@pytest.mark.parametrize("template_style,custom_template,expected_phrases", [ + ( + SlackTemplateStyle.DEFAULT, + None, + [ + "[Firing]", # status block + ":bell: Type: Alert", # context block + "Severity: Info", + ":globe_with_meridians: Cluster: test cluster" + ] + ), + ( + SlackTemplateStyle.LEGACY, + None, + [ + "Prometheus Alert Firing", # header block + "*Source:* `test cluster`" + ] + ), + ( + SlackTemplateStyle.DEFAULT, + "custom_template.j2", + [ + "CUSTOM TEMPLATE:", + ] + ) +]) +def test_slack_template_styles(slack_channel: SlackChannel, template_style, template_name, expected_phrases): + slack_sender = SlackSender( + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name + ) + finding = Finding( + title="Test Template Style", + aggregation_key="test-template-style", + severity=FindingSeverity.INFO, + source=FindingSource.PROMETHEUS, + description="Testing template style rendering" + ) + finding.add_enrichment([MarkdownBlock("This is a test block.")]) + + slack_params = SlackSinkParams( + name="test_slack", + slack_channel=slack_channel.channel_name, + api_key="", + template_style=template_style, + template_name=template_name, + slack_custom_templates={ + "custom_template.j2": """ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "CUSTOM TEMPLATE: {{ title }}" + } + } + """, + }) + + slack_sender.send_finding_to_slack(finding, slack_params, False) + latest_message = slack_channel.get_latest_message() + for phrase in expected_phrases: + assert phrase in latest_message + assert "Test Template Style" in latest_message + assert "This is a test block." in latest_message \ No newline at end of file From abe1c74bf9cc1d76a77359a839d9b19a8b30d054 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Thu, 15 May 2025 11:25:12 +0300 Subject: [PATCH 19/42] saving progress --- src/robusta/core/sinks/sink_factory.py | 2 + .../core/sinks/slack/preview/__init__.py | 2 + .../sinks/slack/preview/slack_sink_preview.py | 14 + .../preview/slack_sink_preview_params.py | 43 ++ src/robusta/core/sinks/slack/slack_sink.py | 11 +- .../sinks/slack/templates/template_loader.py | 24 +- src/robusta/integrations/slack/sender.py | 4 +- .../test_slack_integration_manual.py | 559 ++++++++++++++++++ 8 files changed, 640 insertions(+), 19 deletions(-) create mode 100644 src/robusta/core/sinks/slack/preview/__init__.py create mode 100644 src/robusta/core/sinks/slack/preview/slack_sink_preview.py create mode 100644 src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py create mode 100644 tests/manual_tests/test_slack_integration_manual.py diff --git a/src/robusta/core/sinks/sink_factory.py b/src/robusta/core/sinks/sink_factory.py index dbbc5829f..2a56ffaf3 100644 --- a/src/robusta/core/sinks/sink_factory.py +++ b/src/robusta/core/sinks/sink_factory.py @@ -22,6 +22,7 @@ from robusta.core.sinks.sink_base import SinkBase from robusta.core.sinks.sink_config import SinkConfigBase from robusta.core.sinks.slack import SlackSink, SlackSinkConfigWrapper +from robusta.core.sinks.slack.preview import SlackSinkPreview, SlackSinkPreviewConfigWrapper from robusta.core.sinks.telegram import TelegramSink, TelegramSinkConfigWrapper from robusta.core.sinks.victorops import VictoropsConfigWrapper, VictoropsSink from robusta.core.sinks.webex import WebexSink, WebexSinkConfigWrapper @@ -35,6 +36,7 @@ class SinkFactory: __sink_config_mapping: Dict[Type[SinkConfigBase], Type[SinkBase]] = { SlackSinkConfigWrapper: SlackSink, + SlackSinkPreviewConfigWrapper: SlackSinkPreview, RocketchatSinkConfigWrapper: RocketchatSink, RobustaSinkConfigWrapper: RobustaSink, MsTeamsSinkConfigWrapper: MsTeamsSink, diff --git a/src/robusta/core/sinks/slack/preview/__init__.py b/src/robusta/core/sinks/slack/preview/__init__.py new file mode 100644 index 000000000..83ed80126 --- /dev/null +++ b/src/robusta/core/sinks/slack/preview/__init__.py @@ -0,0 +1,2 @@ +from robusta.core.sinks.slack.preview.slack_sink_preview import SlackSinkPreview +from robusta.core.sinks.slack.preview.slack_sink_params_preview import SlackSinkPreviewConfigWrapper, SlackSinkPreviewParams diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview.py new file mode 100644 index 000000000..08673c855 --- /dev/null +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview.py @@ -0,0 +1,14 @@ +from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewConfigWrapper, SlackSinkPreviewParams +from robusta.core.sinks.slack.slack_sink import SlackSink +from robusta.integrations import slack as slack_module + + +class SlackSinkPreview(SlackSink): + params: SlackSinkPreviewParams + + def __init__(self, sink_config: SlackSinkPreviewConfigWrapper, registry): + self.slack_sender = slack_module.SlackSender( + self.api_key, self.account_id, self.cluster_name, self.signing_key, self.slack_channel, is_preview=True + ) + super().__init__(sink_config.slack_sink, registry, self.slack_sender) + diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py new file mode 100644 index 000000000..acc3561c4 --- /dev/null +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py @@ -0,0 +1,43 @@ +from robusta.core.sinks.sink_base_params import SinkBaseParams +from robusta.core.sinks.sink_config import SinkConfigBase +from robusta.core.sinks.slack import SlackSinkParams +from enum import Enum +from typing import Optional, Dict + + +class SlackTemplateStyle(str, Enum): + DEFAULT = "default" + LEGACY = "legacy" + + +class SlackSinkPreviewParams(SlackSinkParams): + template_style: SlackTemplateStyle = SlackTemplateStyle.DEFAULT # Use "legacy" for old-style formatting + slack_custom_templates: Optional[Dict[str, str]] = None # Template name -> custom template content + template_name: Optional[str] = None + + def get_effective_template_name(self) -> str: + """ + Returns the template name to use for this sink. If template_name is set, use it. + Otherwise, use 'legacy.j2' if template_style is legacy, else 'header.j2'. + """ + if self.template_name: + return self.template_name + if self.template_style == SlackTemplateStyle.LEGACY: + return "legacy.j2" + return "header.j2" + + def get_custom_template(self) -> Optional[str]: + """ + Returns the custom template string for the effective template name, if it exists. + """ + template_name = self.get_effective_template_name() + if self.slack_custom_templates and template_name in self.slack_custom_templates: + return self.slack_custom_templates[template_name] + return None + + +class SlackSinkPreviewConfigWrapper(SinkConfigBase): + slack_sink: SlackSinkPreviewParams + + def get_params(self) -> SinkBaseParams: + return self.slack_sink \ No newline at end of file diff --git a/src/robusta/core/sinks/slack/slack_sink.py b/src/robusta/core/sinks/slack/slack_sink.py index 1f7aa7989..2893977d7 100644 --- a/src/robusta/core/sinks/slack/slack_sink.py +++ b/src/robusta/core/sinks/slack/slack_sink.py @@ -10,13 +10,16 @@ class SlackSink(SinkBase): params: SlackSinkParams - def __init__(self, sink_config: SlackSinkConfigWrapper, registry): + def __init__(self, sink_config: SlackSinkConfigWrapper, registry, slack_sender=None): super().__init__(sink_config.slack_sink, registry) self.slack_channel = sink_config.slack_sink.slack_channel self.api_key = sink_config.slack_sink.api_key - self.slack_sender = slack_module.SlackSender( - self.api_key, self.account_id, self.cluster_name, self.signing_key, self.slack_channel - ) + if slack_sender: + self.slack_sender = slack_sender + else: + self.slack_sender = slack_module.SlackSender( + self.api_key, self.account_id, self.cluster_name, self.signing_key, self.slack_channel + ) self.registry.subscribe("replace_callback_with_string", self) def handle_event(self, event_name: str, **kwargs): diff --git a/src/robusta/core/sinks/slack/templates/template_loader.py b/src/robusta/core/sinks/slack/templates/template_loader.py index 6ec227b37..9d47bff97 100644 --- a/src/robusta/core/sinks/slack/templates/template_loader.py +++ b/src/robusta/core/sinks/slack/templates/template_loader.py @@ -55,41 +55,37 @@ def get_template(self, template_name: str) -> Template: return self._templates[template_name] - def render_to_blocks(self, template_name: str, context: Dict[str, Any]) -> List[Dict[str, Any]]: + def render_to_blocks(self, template: Template, context: Dict[str, Any]) -> List[Dict[str, Any]]: """ - Render a template using the provided context and parse the result as JSON to get Slack blocks. - + Render a Jinja2 Template object using the provided context and parse the result as JSON to get Slack blocks. Args: - template_name: The name of the template file + template: A Jinja2 Template object context: Dictionary of variables to pass to the template - Returns: List of Slack block objects (dictionaries) """ - template = self.get_template(template_name) - try: rendered = template.render(**context) - - # Split by newlines to get multiple blocks and parse each as JSON - blocks = [] blocks = [] for block_str in rendered.strip().split("\n\n"): if not block_str.strip(): continue - try: block_str_fixed = escape_raw_newlines_in_json_strings(block_str) + # Try to parse as JSON, but if it fails, log and skip block = json.loads(block_str_fixed) blocks.append(block) - except json.JSONDecodeError as e: logging.exception(f"Error parsing JSON from template output: {e}") logging.warning(f"Problematic JSON (repr): {repr(block_str)}") - + continue # Skip this block and continue + except Exception as e: + logging.error(f"Unexpected error parsing block: {e}") + logging.warning(f"Problematic JSON (repr): {repr(block_str)}") + continue return blocks except Exception as e: - logging.error(f"Error rendering template {template_name}: {e}") + logging.error(f"Error rendering template: {e}") return [] def render_custom_template_to_blocks(self, custom_template: str, context: Dict[str, Any]) -> List[Dict[str, Any]]: diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 512f1e525..05f4e7c60 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -65,8 +65,9 @@ class SlackSender: verified_api_tokens: Set[str] = set() channel_name_to_id = {} + is_preview = False - def __init__(self, slack_token: str, account_id: str, cluster_name: str, signing_key: str, slack_channel: str): + def __init__(self, slack_token: str, account_id: str, cluster_name: str, signing_key: str, slack_channel: str, is_preview: bool = False): """ Connect to Slack and verify that the Slack token is valid. Return True on success, False on failure @@ -87,6 +88,7 @@ def __init__(self, slack_token: str, account_id: str, cluster_name: str, signing self.signing_key = signing_key self.account_id = account_id self.cluster_name = cluster_name + self.is_preview = is_preview if slack_token not in self.verified_api_tokens: try: diff --git a/tests/manual_tests/test_slack_integration_manual.py b/tests/manual_tests/test_slack_integration_manual.py new file mode 100644 index 000000000..cfc6302ec --- /dev/null +++ b/tests/manual_tests/test_slack_integration_manual.py @@ -0,0 +1,559 @@ +import os +import sys +import logging +from datetime import datetime + +# Add the src directory to the Python path so we can import the modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) + +from robusta.integrations.slack.sender import SlackSender +from robusta.core.reporting.base import Finding, FindingSource, FindingSeverity, FindingStatus, FindingSubject, Link, \ + EnrichmentType +from robusta.core.reporting.consts import FindingSubjectType, FindingType +from robusta.core.reporting.base import LinkType +from robusta.core.reporting.blocks import ( + MarkdownBlock, + HeaderBlock, + DividerBlock, + TableBlock, + ListBlock, + LinkProp, + LinksBlock, + CallbackBlock, + CallbackChoice, + FileBlock +) +from robusta.core.model.base_params import ResourceInfo +from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams +from robusta.core.playbooks.internal.ai_integration import ask_holmes + +# Configure logging - set to DEBUG to see full block details +logging.basicConfig( + level=logging.DEBUG if os.environ.get("DEBUG", "").lower() == "true" else logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) + +# You'll need to provide a Slack API token to run this script +# You can get one from https://api.slack.com/apps +# For testing, you can hardcode a token here, but don't commit it to the repo +# By default, try to get token from env var +SLACK_TOKEN = os.environ.get("SLACK_TOKEN", "xoxb-your-actual-token-here") +# The Slack channel to send messages to +SLACK_CHANNEL = os.environ.get("SLACK_CHANNEL", "test-robusta") + + +# In a real scenario, we'd use @action decorator, but for testing +# we'll just remove the CallbackBlock as it requires the full Robusta action system +# We'll use a simple MarkdownBlock with button simulation instead + +# Create mock data for the Finding +def create_test_finding(title: str, severity: FindingSeverity = FindingSeverity.INFO) -> Finding: + """Create a test finding with the given title and severity""" + # Create a subject first with the correct type + subject = FindingSubject( + name="test-pod", + namespace="default", + subject_type=FindingSubjectType.TYPE_POD, + labels={ + "app": "test-app", + "environment": "test" + }, + annotations={ + "description": "Test annotation" + } + ) + + finding = Finding( + title=title, + source=FindingSource.PROMETHEUS, + severity=severity, + aggregation_key=f"test-{title}", + finding_type=FindingType.ISSUE, + description="This is a test finding for Slack message formatting", + subject=subject + ) + + # We need to mock the service since TopServiceResolver won't work in test environment + # Create a simple mock for the service object + class MockService: + def __init__(self, name, namespace, resource_type): + self.name = name + self.namespace = namespace + self.resource_type = resource_type + + def get_resource_key(self): + return f"{self.namespace}/{self.name}" + + # Set the service directly + finding.service = MockService( + name="test-deployment", + namespace="default", + resource_type="Deployment" + ) + + # Add links as proper Link objects + + finding.links = [ + Link(url="https://example.com/logs", name="View Logs"), + Link(url="https://example.com/grafana", name="Grafana", type=LinkType.PROMETHEUS_GENERATOR_URL) + ] + + return finding + + +def add_basic_blocks(finding: Finding) -> None: + """Add some basic blocks to the finding""" + finding.add_enrichment([ + HeaderBlock("Kubernetes Resource Information"), + MarkdownBlock("This alert was triggered by high resource usage"), + TableBlock( + rows=[ + ["Namespace", "default"], + ["Pod", "test-pod"], + ["Container", "test-container"], + ["CPU Usage", "250m"], + ["Memory Usage", "512Mi"], + ], + headers=["Property", "Value"], + table_name="Resource Usage", + ), + DividerBlock(), + MarkdownBlock("*Recent Events:*"), + ListBlock([ + "Pod started at 08:15:30", + "CPU usage spiked at 08:45:22", + "Memory usage increased at 08:47:15" + ]), + LinksBlock(links=[ + LinkProp(text="View Pod Details", url="https://example.com/pod"), + LinkProp(text="View Container Logs", url="https://example.com/logs") + ]), + # Instead of using CallbackBlock which requires the @action decorator + MarkdownBlock("*Available Actions:* (Simulated buttons)\n• Restart Pod\n• Scale Deployment") + ]) + + +def add_complex_table(finding: Finding) -> None: + """Add a more complex table to the finding""" + finding.add_enrichment([ + HeaderBlock("Detailed Resource Metrics"), + TableBlock( + rows=[ + ["pod-1", "Running", "0.25", "256Mi", "node-1", "app=web,tier=frontend"], + ["pod-2", "Running", "0.50", "512Mi", "node-1", "app=web,tier=frontend"], + ["pod-3", "CrashLoopBackOff", "0.10", "128Mi", "node-2", "app=db,tier=backend"], + ["pod-4", "Running", "0.75", "1024Mi", "node-2", "app=api,tier=backend"], + ["pod-5", "Pending", "0", "0", "None", "app=cache,tier=backend"], + ], + headers=["Pod", "Status", "CPU", "Memory", "Node", "Labels"], + table_name="Cluster Pod Overview", + ) + ]) + + +def create_holmes_callback(finding: Finding) -> None: + """Add a Holmes AI callback to the finding""" + # Instead of using a real callback, we'll simulate it with markdown + finding.add_enrichment([ + MarkdownBlock("*AI Investigation:*"), + MarkdownBlock("šŸ¤– *Ask Holmes AI* to investigate this alert (simulated button)"), + MarkdownBlock("_Holmes would analyze this alert and provide insights on cause and remediation._") + ]) + + +def create_crash_loop_finding() -> Finding: + """Create a sample crash loop finding with exact requested data""" + subject = FindingSubject( + name="payment-processing-worker-747ccfb9db-r7z7m", + namespace="default", + subject_type=FindingSubjectType.TYPE_POD, + node="gke-1-4d3c8387-d24h", + labels={ + "app": "payment-processing-worker", + "pod-template-hash": "747ccfb9db" + }, + annotations={} + ) + + finding = Finding( + title="Crashing pod payment-processing-worker-747ccfb9db-r7z7m in namespace default", + source=FindingSource.KUBERNETES_API_SERVER, + severity=FindingSeverity.HIGH, + aggregation_key="CrashLoopBackoff", + finding_type=FindingType.ISSUE, + subject=subject, + failure=True, + fingerprint="d7c6e2ac798ee28c8a4ac6c8c33de52e60f0b019cd10d78ac04831d44cf86389", + starts_at=datetime(2025, 3, 23, 20, 8, 43, 822328) + ) + + # Add the crash info enrichment + finding.add_enrichment([ + TableBlock( + rows=[ + ["Container", "payment-processing-container"], + ["Restarts", 130], + ["Status", "WAITING"], + ["Reason", "CrashLoopBackOff"] + ], + headers=["label", "value"], + table_name="*Crash Info*", + metadata={"format": "vertical"} + ), + TableBlock( + rows=[ + ["Status", "TERMINATED"], + ["Reason", "Completed"], + ["Started at", "2025-03-23T18:08:32Z"], + ["Finished at", "2025-03-23T18:08:32Z"] + ], + headers=["label", "value"], + table_name="*Previous Container*", + metadata={"format": "vertical"} + ) + ], title="Container Crash information", enrichment_type=EnrichmentType.crash_info) + + # Add the logs enrichment + finding.add_enrichment([ + FileBlock( + filename="payment-processing-worker-747ccfb9db-r7z7m.log", + contents=b'Environment variable DEPLOY_ENV is undefined\n' + ) + ], title="Logs", enrichment_type=EnrichmentType.text_file) + + return finding + + +def create_node_saturation_finding() -> Finding: + """Create a node saturation finding with exact requested data""" + subject = FindingSubject( + name="gke-1-4d3c8387-c4bp", + subject_type=FindingSubjectType.TYPE_NODE, + namespace="default", + node="gke-1-4d3c8387-c4bp", + container="node-exporter", + labels={ + "beta.kubernetes.io/arch": "amd64", + "beta.kubernetes.io/instance-type": "e2-standard-2", + "beta.kubernetes.io/os": "linux", + "cloud.google.com/gke-boot-disk": "pd-balanced", + "cloud.google.com/gke-container-runtime": "containerd", + "cloud.google.com/gke-cpu-scaling-level": "2", + "cloud.google.com/gke-logging-variant": "DEFAULT", + "cloud.google.com/gke-max-pods-per-node": "110", + "cloud.google.com/gke-memory-gb-scaling-level": "8", + "cloud.google.com/gke-nodepool": "pool-1", + "cloud.google.com/gke-os-distribution": "cos", + "cloud.google.com/gke-provisioning": "spot", + "cloud.google.com/gke-spot": "true", + "cloud.google.com/gke-stack-type": "IPV4", + "cloud.google.com/machine-family": "e2", + "cloud.google.com/private-node": "false", + "failure-domain.beta.kubernetes.io/region": "us-central1", + "failure-domain.beta.kubernetes.io/zone": "us-central1-c", + "kubernetes.io/arch": "amd64", + "kubernetes.io/hostname": "gke-4d3c8387-c4bp" + }, + annotations={} + ) + + finding = Finding( + title="System saturated, load per core is very high.", + source=FindingSource.PROMETHEUS, + severity=FindingSeverity.LOW, + aggregation_key="NodeSystemSaturation", + finding_type=FindingType.ISSUE, + subject=subject, + description="System load per core at 10.128.0.77:9104 has been above 2 for the last 15 minutes, is currently at 5.22.\nThis might indicate this instance resources saturation and can cause it becoming unresponsive.\n", + starts_at=datetime(2025, 3, 23, 20, 9, 4, 838000) + ) + + # Could add enrichments here if needed + + return finding + + +def create_datadog_agent_crash_finding() -> Finding: + """Create a datadog agent crash finding""" + subject = FindingSubject( + name="datadog-agent-w49mv", + subject_type=FindingSubjectType.TYPE_POD, + namespace="default", + node="gke-1-4d3c8387-mm23", + labels={ + "agent.datadoghq.com/component": "agent", + "agent.datadoghq.com/name": "datadog", + "agent.datadoghq.com/provider": "", + "app.kubernetes.io/component": "agent", + "app.kubernetes.io/instance": "datadog-agent", + "app.kubernetes.io/managed-by": "datadog-operator", + "app.kubernetes.io/name": "datadog-agent-deployment", + "app.kubernetes.io/part-of": "default-datadog" + }, + annotations={} + ) + + finding = Finding( + title="Crashing pod datadog-agent-w49mv in namespace default", + source=FindingSource.KUBERNETES_API_SERVER, + severity=FindingSeverity.HIGH, + aggregation_key="CrashLoopBackoff", + finding_type=FindingType.ISSUE, + subject=subject, + failure=True, + starts_at=datetime(2025, 3, 23, 20, 9, 2, 211000) + ) + + # Could add crash info enrichment here if needed + + return finding + + +def create_cpu_throttling_finding() -> Finding: + """Create a CPU throttling finding with exact requested data""" + subject = FindingSubject( + name="vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm", + subject_type=FindingSubjectType.TYPE_POD, + namespace="default", + node="gke-4ksv", + container="vmagent", + labels={ + "app.kubernetes.io/component": "monitoring", + "app.kubernetes.io/instance": "vmks-victoria-metrics-k8s-stack", + "app.kubernetes.io/name": "vmagent", + "managed-by": "vm-operator", + "pod-template-hash": "6dcd499cb5", + "alertname": "CPUThrottlingHigh", + "container": "vmagent", + "namespace": "default", + "pod": "vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm", + "prometheus": "default/robusta-kube-prometheus-st-prometheus", + "severity": "info" + }, + annotations={ + "description": "71.13% throttling of CPU in namespace default for container vmagent in pod vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm.", + "runbook_url": "https://runbooks.prometheus-operator.dev/runbooks/kubernetes/cputhrottlinghigh", + "summary": "Processes experience elevated CPU throttling." + } + ) + + # Create the finding + finding = Finding( + title="Processes experience elevated CPU throttling.", + source=FindingSource.PROMETHEUS, + severity=FindingSeverity.INFO, + aggregation_key="CPUThrottlingHigh", + finding_type=FindingType.ISSUE, + subject=subject, + description="71.13% throttling of CPU in namespace default for container vmagent in pod vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm.", + failure=True, + fingerprint="9674d957b35e0e9f", + starts_at=datetime(2025, 3, 22, 10, 22, 10, 219000), + add_silence_url=True, + silence_labels={ + "alertname": "CPUThrottlingHigh", + "container": "vmagent", + "namespace": "default", + "pod": "vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm", + "prometheus": "default/robusta-kube-prometheus-st-prometheus", + "severity": "info" + } + ) + + # Add explanation enrichment + finding.add_enrichment([ + MarkdownBlock( + 'šŸ“˜ *Alert Explanation:* This pod is throttled due to its CPU limit. This can occur even when CPU usage is far below the limit. '), + MarkdownBlock( + "šŸ›  *Robusta's Recommendation:* Remove this pod's CPU limit entirely. ") + ], annotations={"unfurl": False}) + + # Instead of adding an SVG file which can cause JSON serialization issues, + # Let's add a simpler enrichment with just a table for CPU usage + finding.add_enrichment([ + TableBlock( + rows=[ + ["Current CPU Usage", "19.5m"], + ["CPU Request", "100m"], + ["CPU Limit", "300m"], + ["Throttling Percentage", "71.13%"], + ["Duration", "Last 5 minutes"] + ], + headers=["Metric", "Value"], + table_name="*CPU Usage*" + ) + ], title="Resources", enrichment_type=EnrichmentType.graph) + + # We're not including the SVG graph anymore to avoid JSON serialization issues + + # Add alert labels enrichment + finding.add_enrichment([ + TableBlock( + rows=[ + ["alertname", "CPUThrottlingHigh"], + ["container", "vmagent"], + ["namespace", "default"], + ["pod", "vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm"], + ["prometheus", "default/robusta-kube-prometheus-st-prometheus"], + ["severity", "info"] + ], + headers=["label", "value"], + table_name="*Alert labels*", + metadata={"format": "vertical"} + ) + ], title="Alert labels", enrichment_type=EnrichmentType.alert_labels, annotations={"attachment": True}) + + # Add Prometheus link + finding.add_link( + Link( + url="http://robusta-kube-prometheus-st-prometheus.default:9090/graph?g0.expr=sum+by+%28cluster%2C+container%2C+pod%2C+namespace%29+%28increase%28container_cpu_cfs_throttled_periods_total%7Bcontainer%21%3D%22%22%7D%5B5m%5D%29%29+%2F+sum+by+%28cluster%2C+container%2C+pod%2C+namespace%29+%28increase%28container_cpu_cfs_periods_total%5B5m%5D%29%29+%3E+%2825+%2F+100%29&g0.tab=1", + name="View Graph", + type=LinkType.PROMETHEUS_GENERATOR_URL + ) + ) + + return finding + + +def main(): + if not SLACK_TOKEN: + logging.error("SLACK_TOKEN environment variable not set. Please set it to proceed.") + sys.exit(1) + + # Enable detailed block logging if DEBUG=true + if os.environ.get("DEBUG", "").lower() == "true": + logging.info("DEBUG mode enabled - will print detailed block information") + logging.info("Run script with DEBUG=false to disable detailed logging") + + # Initialize the SlackSender + sender = SlackSender( + slack_token=SLACK_TOKEN, + account_id="test-account", + cluster_name="test-cluster", + signing_key="test-signing-key", + slack_channel=SLACK_CHANNEL + ) + + # Create the SlackSinkParams + sink_params = SlackSinkParams( + name="test-sink", + slack_channel=SLACK_CHANNEL, + api_key=SLACK_TOKEN, + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000 + ) + + # Create a SlackSinkParams with legacy style + legacy_template_params = SlackSinkParams( + name="legacy-template-sink", + slack_channel=SLACK_CHANNEL, + api_key=SLACK_TOKEN, + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000, + template_style="legacy" # Use the legacy template style + ) + + # Create a SlackSinkParams with a custom template + custom_template_params = SlackSinkParams( + name="custom-template-sink", + slack_channel=SLACK_CHANNEL, + api_key=SLACK_TOKEN, + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000, + template_name="custom.j2", + slack_custom_templates={ + "custom.j2": """ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{{ status_emoji }} *CUSTOM TEMPLATE: {{ title }}*" + } + } + + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": ":rotating_light: {{ alert_type }} on cluster {{ cluster_name }}" + }, + { + "type": "mrkdwn", + "text": "{{ severity_emoji }} {{ severity }} severity" + } + {% if resource_text %} + ,{ + "type": "mrkdwn", + "text": "{{ resource_emoji }} {{ resource_text }}" + } + {% endif %} + ] + } + """ + } + ) + + # Create all findings + crash_loop_finding = create_crash_loop_finding() + node_saturation_finding = create_node_saturation_finding() + datadog_agent_crash_finding = create_datadog_agent_crash_finding() + cpu_throttling_finding = create_cpu_throttling_finding() + + # Send findings to Slack with standard templates + logging.info(f"Sending crash loop finding to Slack channel (DEFAULT TEMPLATE): {SLACK_CHANNEL}...") + sender.send_finding_to_slack(crash_loop_finding, sink_params, platform_enabled=True) + + # Send findings to Slack with legacy template style + logging.info(f"Sending node saturation finding to Slack channel with LEGACY TEMPLATE: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(node_saturation_finding, legacy_template_params, platform_enabled=True) + + # Send findings to Slack with custom templates + logging.info(f"Sending datadog agent crash finding to Slack channel with CUSTOM TEMPLATE: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(datadog_agent_crash_finding, custom_template_params, platform_enabled=True) + + logging.info(f"Sending CPU throttling finding to Slack channel with CUSTOM TEMPLATE: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(cpu_throttling_finding, custom_template_params, platform_enabled=True) + + logging.info(f"Test messages sent successfully to Slack channel: {SLACK_CHANNEL}") + + # Uncomment the below code to send the original test findings + """ + # Create test findings with different severities + finding_info = create_test_finding("Test INFO Alert", FindingSeverity.INFO) + finding_low = create_test_finding("Test LOW Alert", FindingSeverity.LOW) + finding_medium = create_test_finding("Test MEDIUM Alert", FindingSeverity.MEDIUM) + finding_high = create_test_finding("Test HIGH Alert", FindingSeverity.HIGH) + + # Add different types of content to each finding + add_basic_blocks(finding_info) + add_complex_table(finding_low) + + # Combine basic and complex content for medium severity + add_basic_blocks(finding_medium) + add_complex_table(finding_medium) + + # Add AI callback for high severity + add_basic_blocks(finding_high) + create_holmes_callback(finding_high) + + # Send all test findings to Slack + logging.info(f"Sending INFO finding to Slack channel: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(finding_info, sink_params, platform_enabled=True) + + logging.info(f"Sending LOW finding to Slack channel: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(finding_low, sink_params, platform_enabled=True) + + logging.info(f"Sending MEDIUM finding to Slack channel: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(finding_medium, sink_params, platform_enabled=True) + + logging.info(f"Sending HIGH finding with Holmes callback to Slack channel: {SLACK_CHANNEL}...") + sender.send_finding_to_slack(finding_high, sink_params, platform_enabled=True) + """ + + +if __name__ == "__main__": + main() \ No newline at end of file From cca0d5772a19959672e484cd08ad6cfc91aac653 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Thu, 15 May 2025 12:17:18 +0300 Subject: [PATCH 20/42] added tests --- tests/manual_tests/test_slack_integration_manual.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/manual_tests/test_slack_integration_manual.py b/tests/manual_tests/test_slack_integration_manual.py index cfc6302ec..0f561306c 100644 --- a/tests/manual_tests/test_slack_integration_manual.py +++ b/tests/manual_tests/test_slack_integration_manual.py @@ -359,13 +359,14 @@ def create_cpu_throttling_finding() -> Finding: "severity": "info" } ) - + table_rows = [["Row1_Col1", "Row1_Col2"], ["Row2_Col1", "Row2_Col2"]] + table_block = TableBlock(table_rows, headers=["Header1", "Header2"]) # Add explanation enrichment finding.add_enrichment([ MarkdownBlock( 'šŸ“˜ *Alert Explanation:* This pod is throttled due to its CPU limit. This can occur even when CPU usage is far below the limit. '), MarkdownBlock( - "šŸ›  *Robusta's Recommendation:* Remove this pod's CPU limit entirely. ") + "šŸ›  *Robusta's Recommendation:* Remove this pod's CPU limit entirely. "),table_block ], annotations={"unfurl": False}) # Instead of adding an SVG file which can cause JSON serialization issues, From ea819798b726cb0b0092811f8b5f5eba68d2aa49 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Thu, 15 May 2025 14:33:47 +0300 Subject: [PATCH 21/42] refactoring changes --- src/robusta/integrations/slack/sender.py | 183 ++++++++++++++++++----- 1 file changed, 143 insertions(+), 40 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 05f4e7c60..c8ecbab66 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -1,23 +1,18 @@ import copy -import json import logging -import os import ssl import tempfile import re from datetime import datetime, timedelta from itertools import chain from typing import Any, Dict, List, Optional, Set - +import json import certifi import humanize from dateutil import tz from slack_sdk import WebClient from slack_sdk.errors import SlackApiError from slack_sdk.http_retry import all_builtin_retry_handlers - -# Since we're assuming Jinja2 is always present, we can import directly -from jinja2 import Template from robusta.core.sinks.slack.templates.template_loader import template_loader from robusta.core.model.base_params import AIInvestigateParams, ResourceInfo @@ -53,6 +48,8 @@ from robusta.core.sinks.common import ChannelTransformer from robusta.core.sinks.sink_base import KeyT from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams +from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewParams + from robusta.core.sinks.transformer import Transformer ACTION_TRIGGER_PLAYBOOK = "trigger_playbook" @@ -65,7 +62,6 @@ class SlackSender: verified_api_tokens: Set[str] = set() channel_name_to_id = {} - is_preview = False def __init__(self, slack_token: str, account_id: str, cluster_name: str, signing_key: str, slack_channel: str, is_preview: bool = False): """ @@ -346,7 +342,6 @@ def __send_blocks_to_slack( kwargs = {"thread_ts": thread_ts} else: kwargs = {} - # Create a single attachment with a consistent color, containing all enrichment blocks resp = self.slack_client.chat_postMessage( channel=channel, text=message, @@ -429,10 +424,9 @@ def extract_mentions(title) -> (str, str): return title, mention - - def __create_finding_header( - self, finding: Finding, status: FindingStatus, platform_enabled: bool, include_investigate_link: bool, - sink_params = None + def __create_finding_header_preview( + self, finding: Finding, status: FindingStatus, platform_enabled: bool, include_investigate_link: bool, + sink_params: SlackSinkPreviewParams = None ) -> List[SlackBlock]: title = finding.title.removeprefix("[RESOLVED] ") @@ -440,12 +434,12 @@ def __create_finding_header( sev = finding.severity - # Prepare data for template status_text = "Firing" if status == FindingStatus.FIRING else "Resolved" status_emoji = "āš ļø" if status == FindingStatus.FIRING else "āœ…" - investigate_uri = finding.get_investigate_uri(self.account_id, self.cluster_name) if platform_enabled else "" - + investigate_uri = finding.get_investigate_uri(self.account_id, + self.cluster_name) if platform_enabled else "" + # Get alert type information if finding.source == FindingSource.PROMETHEUS: alert_type = "Alert" @@ -453,16 +447,16 @@ def __create_finding_header( alert_type = "K8s Event" else: alert_type = "Notification" - + # Prepare resource text and emoji if available resource_text = "" resource_emoji = ":package:" - + if finding.subject: subject_kind = finding.subject.subject_type.value subject_namespace = finding.subject.namespace subject_name = finding.subject.name - + if subject_kind and subject_name: # Choose emoji based on kind if subject_kind.lower() == "pod": @@ -477,13 +471,13 @@ def __create_finding_header( resource_emoji = ":clock1:" elif subject_kind.lower() == "statefulset": resource_emoji = ":chains:" - + # Format as Kind/Namespace/Name if subject_namespace: resource_text = f"{subject_kind}/{subject_namespace}/{subject_name}" else: resource_text = f"{subject_kind}/{subject_name}" - + # Prepare template context template_context = { "title": title, @@ -513,6 +507,31 @@ def __create_finding_header( else: return template_loader.render_file_template_to_blocks(template_name, template_context) + def __create_finding_header( + self, finding: Finding, status: FindingStatus, platform_enabled: bool, include_investigate_link: bool + ) -> MarkdownBlock: + title = finding.title.removeprefix("[RESOLVED] ") + + title, mention = self.extract_mentions(title) + + sev = finding.severity + if finding.source == FindingSource.PROMETHEUS: + status_name: str = ( + f"{status.to_emoji()} `Prometheus Alert Firing` {status.to_emoji()}" + if status == FindingStatus.FIRING + else f"{status.to_emoji()} *Prometheus resolved*" + ) + elif finding.source == FindingSource.KUBERNETES_API_SERVER: + status_name: str = "šŸ‘€ *K8s event detected*" + else: + status_name: str = "šŸ‘€ *Notification*" + if platform_enabled and include_investigate_link: + title = f"<{finding.get_investigate_uri(self.account_id, self.cluster_name)}|*{title}*>" + return MarkdownBlock( + f"""{status_name} {sev.to_emoji()} *{sev.name.capitalize()}* +{title}{mention}""" + ) + def __create_links( self, finding: Finding, @@ -642,6 +661,90 @@ def send_finding_to_slack( sink_params: SlackSinkParams, platform_enabled: bool, thread_ts: str = None, + ) -> str: + if self.is_preview: + return self.send_finding_to_slack_preview( + finding=finding, + sink_params=sink_params, + platform_enabled=platform_enabled, + thread_ts=thread_ts + ) + + blocks: List[BaseBlock] = [] + attachment_blocks: List[BaseBlock] = [] + + slack_channel = ChannelTransformer.template( + sink_params.channel_override, + sink_params.slack_channel, + self.cluster_name, + finding.subject.labels, + finding.subject.annotations, + ) + + if finding.finding_type == FindingType.AI_ANALYSIS: + # holmes analysis message needs special handling + self.send_holmes_analysis(finding, slack_channel, platform_enabled, thread_ts) + return "" # [arik] Looks like the return value here is not used, needs to be removed + + status: FindingStatus = ( + FindingStatus.RESOLVED if finding.title.startswith("[RESOLVED]") else FindingStatus.FIRING + ) + if finding.title: + blocks.append(self.__create_finding_header(finding, status, platform_enabled, sink_params.investigate_link)) + + links_block: LinksBlock = self.__create_links( + finding, platform_enabled, sink_params.investigate_link, sink_params.prefer_redirect_to_platform + ) + blocks.append(links_block) + + if HOLMES_ENABLED and HOLMES_ASK_SLACK_BUTTON_ENABLED: + blocks.append(self.__create_holmes_callback(finding)) + + blocks.append(MarkdownBlock(text=f"*Source:* `{self.cluster_name}`")) + if finding.description: + if finding.source == FindingSource.PROMETHEUS: + blocks.append(MarkdownBlock(f"{Emojis.Alert.value} *Alert:* {finding.description}")) + elif finding.source == FindingSource.KUBERNETES_API_SERVER: + blocks.append( + MarkdownBlock(f"{Emojis.K8Notification.value} *K8s event detected:* {finding.description}") + ) + else: + blocks.append(MarkdownBlock(f"{Emojis.K8Notification.value} *Notification:* {finding.description}")) + + unfurl = True + for enrichment in finding.enrichments: + if enrichment.annotations.get(EnrichmentAnnotation.SCAN, False): + enrichment.blocks = [Transformer.scanReportBlock_to_fileblock(b) for b in enrichment.blocks] + + # if one of the enrichment specified unfurl=False, this slack message will contain unfurl=False + unfurl = bool(unfurl and enrichment.annotations.get(SlackAnnotations.UNFURL, True)) + if enrichment.annotations.get(SlackAnnotations.ATTACHMENT): + attachment_blocks.extend(enrichment.blocks) + else: + blocks.extend(enrichment.blocks) + + blocks.append(DividerBlock()) + + if len(attachment_blocks): + attachment_blocks.append(DividerBlock()) + + return self.__send_blocks_to_slack( + blocks, + attachment_blocks, + finding.title, + sink_params, + unfurl, + status, + slack_channel, + thread_ts=thread_ts, + ) + + def send_finding_to_slack_preview( + self, + finding: Finding, + sink_params: SlackSinkParams, + platform_enabled: bool, + thread_ts: str = None, ) -> str: blocks: List[BaseBlock] = [] attachment_blocks: List[BaseBlock] = [] @@ -663,10 +766,11 @@ def send_finding_to_slack( status: FindingStatus = ( FindingStatus.RESOLVED if finding.title.startswith("[RESOLVED]") else FindingStatus.FIRING ) - + # Get JIRA-style header blocks if finding.title: - header_blocks = self.__create_finding_header(finding, status, platform_enabled, sink_params.investigate_link, sink_params) + header_blocks = self.__create_finding_header_preview(finding, status, platform_enabled, + sink_params.investigate_link, sink_params) # Description handling - moved above the buttons if finding.description: @@ -691,23 +795,23 @@ def send_finding_to_slack( # if one of the enrichment specified unfurl=False, this slack message will contain unfurl=False unfurl = bool(unfurl and enrichment.annotations.get(SlackAnnotations.UNFURL, True)) - + # Separate file blocks from normal blocks file_blocks_in_enrichment = [b for b in enrichment.blocks if isinstance(b, FileBlock)] non_file_blocks = [b for b in enrichment.blocks if not isinstance(b, FileBlock)] - + # Collect file blocks all_file_blocks.extend(file_blocks_in_enrichment) - + # Put non-file blocks in the attachment attachment_blocks.extend(non_file_blocks) - + # Add file blocks to the main blocks for proper handling blocks.extend(all_file_blocks) - + # No divider in the main blocks - # We need to create a minimal version of __send_blocks_to_slack + # We need to create a minimal version of __send_blocks_to_slack # that can handle both our header blocks and regular blocks file_blocks = add_pngs_for_all_svgs([b for b in blocks if isinstance(b, FileBlock)]) if not sink_params.send_svg: @@ -722,40 +826,40 @@ def send_finding_to_slack( message, error_msg = self.prepare_slack_text( finding.title, max_log_file_limit_kb=sink_params.max_log_file_limit_kb, files=file_blocks ) - + # Convert BaseBlocks to Slack blocks output_blocks = [] for block in other_blocks: output_blocks.extend(self.__to_slack(block, sink_params.name)) - + attachment_slack_blocks = [] for block in attachment_blocks: attachment_slack_blocks.extend(self.__to_slack(block, sink_params.name)) # Combine with header blocks all_blocks = header_blocks + output_blocks - + try: if thread_ts: kwargs = {"thread_ts": thread_ts} else: kwargs = {} - + # Create a single attachment with all blocks and a divider at the end attachments = [] - + # Add divider to the end of attachment blocks if there are any all_attachment_blocks = attachment_slack_blocks.copy() if attachment_slack_blocks else [] - + # Always add a divider at the end all_attachment_blocks.append({"type": "divider"}) - + # Create a single attachment with the status color attachments = [{ "color": status.to_color_hex(), "blocks": all_attachment_blocks }] - + # Detailed logging of blocks before sending to Slack logging.debug( f"SENDING TO SLACK - FULL BLOCKS DETAIL:\n" @@ -767,7 +871,7 @@ def send_finding_to_slack( f"ATTACHMENT BLOCKS: {json.dumps(all_attachment_blocks, indent=2)}\n" f"ATTACHMENTS: {json.dumps(attachments, indent=2)}\n" ) - + # Send the message with our JIRA-style headers and attachments resp = self.slack_client.chat_postMessage( channel=slack_channel, @@ -779,17 +883,16 @@ def send_finding_to_slack( unfurl_media=unfurl, **kwargs, ) - + # Store channel id for future use self.channel_name_to_id[slack_channel] = resp["channel"] return resp["ts"] - + except Exception as e: logging.error( f"error sending message to slack\ne={e}\ntext={message}\nchannel={slack_channel}" ) return "" - def send_or_update_summary_message( self, group_by_classification_header: List[str], From 5df775db7ed93d60d52b8bae67390382b4cc0b62 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Thu, 15 May 2025 14:37:52 +0300 Subject: [PATCH 22/42] misc changes --- .../preview/slack_sink_preview_params.py | 2 +- .../core/sinks/slack/slack_sink_params.py | 34 ++----------------- 2 files changed, 3 insertions(+), 33 deletions(-) diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py index acc3561c4..433714d7b 100644 --- a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py @@ -40,4 +40,4 @@ class SlackSinkPreviewConfigWrapper(SinkConfigBase): slack_sink: SlackSinkPreviewParams def get_params(self) -> SinkBaseParams: - return self.slack_sink \ No newline at end of file + return self.slack_sink diff --git a/src/robusta/core/sinks/slack/slack_sink_params.py b/src/robusta/core/sinks/slack/slack_sink_params.py index 767f532e3..b9145453e 100644 --- a/src/robusta/core/sinks/slack/slack_sink_params.py +++ b/src/robusta/core/sinks/slack/slack_sink_params.py @@ -1,16 +1,10 @@ from robusta.core.sinks.sink_base_params import SinkBaseParams from robusta.core.sinks.sink_config import SinkConfigBase from robusta.core.sinks.common import ChannelTransformer -from enum import Enum -from typing import Optional, Dict, Literal +from typing import Optional from pydantic import validator -class SlackTemplateStyle(str, Enum): - DEFAULT = "default" - LEGACY = "legacy" - - class SlackSinkParams(SinkBaseParams): slack_channel: str api_key: str @@ -18,30 +12,6 @@ class SlackSinkParams(SinkBaseParams): max_log_file_limit_kb: int = 1000 investigate_link: bool = True - template_style: SlackTemplateStyle = SlackTemplateStyle.DEFAULT # Use "legacy" for old-style formatting - slack_custom_templates: Optional[Dict[str, str]] = None # Template name -> custom template content - template_name: Optional[str] = None - - def get_effective_template_name(self) -> str: - """ - Returns the template name to use for this sink. If template_name is set, use it. - Otherwise, use 'legacy.j2' if template_style is legacy, else 'header.j2'. - """ - if self.template_name: - return self.template_name - if self.template_style == SlackTemplateStyle.LEGACY: - return "legacy.j2" - return "header.j2" - - def get_custom_template(self) -> Optional[str]: - """ - Returns the custom template string for the effective template name, if it exists. - """ - template_name = self.get_effective_template_name() - if self.slack_custom_templates and template_name in self.slack_custom_templates: - return self.slack_custom_templates[template_name] - return None - @classmethod def _supports_grouping(cls): return True @@ -59,4 +29,4 @@ class SlackSinkConfigWrapper(SinkConfigBase): slack_sink: SlackSinkParams def get_params(self) -> SinkBaseParams: - return self.slack_sink \ No newline at end of file + return self.slack_sink From ed0178c0c121e354002f5ef0bb479313488b7a21 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Thu, 15 May 2025 17:39:21 +0300 Subject: [PATCH 23/42] changess --- .../core/sinks/slack/preview/slack_sink_preview.py | 5 +---- .../sinks/slack/preview/slack_sink_preview_params.py | 4 ++-- src/robusta/core/sinks/slack/slack_sink.py | 11 ++++------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview.py index 08673c855..609967dd2 100644 --- a/src/robusta/core/sinks/slack/preview/slack_sink_preview.py +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview.py @@ -7,8 +7,5 @@ class SlackSinkPreview(SlackSink): params: SlackSinkPreviewParams def __init__(self, sink_config: SlackSinkPreviewConfigWrapper, registry): - self.slack_sender = slack_module.SlackSender( - self.api_key, self.account_id, self.cluster_name, self.signing_key, self.slack_channel, is_preview=True - ) - super().__init__(sink_config.slack_sink, registry, self.slack_sender) + super().__init__(sink_config.slack_sink_preview, registry, is_preview=True) diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py index 433714d7b..b7b80cdca 100644 --- a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py @@ -37,7 +37,7 @@ def get_custom_template(self) -> Optional[str]: class SlackSinkPreviewConfigWrapper(SinkConfigBase): - slack_sink: SlackSinkPreviewParams + slack_sink_preview: SlackSinkPreviewParams def get_params(self) -> SinkBaseParams: - return self.slack_sink + return self.slack_sink_preview diff --git a/src/robusta/core/sinks/slack/slack_sink.py b/src/robusta/core/sinks/slack/slack_sink.py index 2893977d7..57a82a610 100644 --- a/src/robusta/core/sinks/slack/slack_sink.py +++ b/src/robusta/core/sinks/slack/slack_sink.py @@ -10,16 +10,13 @@ class SlackSink(SinkBase): params: SlackSinkParams - def __init__(self, sink_config: SlackSinkConfigWrapper, registry, slack_sender=None): + def __init__(self, sink_config: SlackSinkConfigWrapper, registry, is_preview=False): super().__init__(sink_config.slack_sink, registry) self.slack_channel = sink_config.slack_sink.slack_channel self.api_key = sink_config.slack_sink.api_key - if slack_sender: - self.slack_sender = slack_sender - else: - self.slack_sender = slack_module.SlackSender( - self.api_key, self.account_id, self.cluster_name, self.signing_key, self.slack_channel - ) + self.slack_sender = slack_module.SlackSender( + self.api_key, self.account_id, self.cluster_name, self.signing_key, self.slack_channel, is_preview + ) self.registry.subscribe("replace_callback_with_string", self) def handle_event(self, event_name: str, **kwargs): From 567372cfac0899640b20b0bb03aa167f4e670aef Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Thu, 15 May 2025 18:43:35 +0300 Subject: [PATCH 24/42] working version --- src/robusta/core/model/runner_config.py | 2 ++ src/robusta/core/sinks/sink_factory.py | 3 ++- src/robusta/core/sinks/slack/__init__.py | 4 +++- src/robusta/core/sinks/slack/preview/__init__.py | 2 -- src/robusta/core/sinks/slack/preview/slack_sink_preview.py | 3 +-- .../core/sinks/slack/preview/slack_sink_preview_params.py | 2 +- src/robusta/core/sinks/slack/slack_sink.py | 7 ++++--- 7 files changed, 13 insertions(+), 10 deletions(-) delete mode 100644 src/robusta/core/sinks/slack/preview/__init__.py diff --git a/src/robusta/core/model/runner_config.py b/src/robusta/core/model/runner_config.py index 138bb60b2..5edb08b8e 100644 --- a/src/robusta/core/model/runner_config.py +++ b/src/robusta/core/model/runner_config.py @@ -18,6 +18,7 @@ from robusta.core.sinks.rocketchat.rocketchat_sink_params import RocketchatSinkConfigWrapper from robusta.core.sinks.servicenow.servicenow_sink_params import ServiceNowSinkConfigWrapper from robusta.core.sinks.slack.slack_sink_params import SlackSinkConfigWrapper +from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewConfigWrapper from robusta.core.sinks.mail.mail_sink_params import MailSinkConfigWrapper from robusta.core.sinks.telegram.telegram_sink_params import TelegramSinkConfigWrapper from robusta.core.sinks.victorops.victorops_sink_params import VictoropsConfigWrapper @@ -55,6 +56,7 @@ class RunnerConfig(BaseModel): Union[ RobustaSinkConfigWrapper, SlackSinkConfigWrapper, + SlackSinkPreviewConfigWrapper, DataDogSinkConfigWrapper, KafkaSinkConfigWrapper, MsTeamsSinkConfigWrapper, diff --git a/src/robusta/core/sinks/sink_factory.py b/src/robusta/core/sinks/sink_factory.py index 2a56ffaf3..96ec8f3d3 100644 --- a/src/robusta/core/sinks/sink_factory.py +++ b/src/robusta/core/sinks/sink_factory.py @@ -22,7 +22,8 @@ from robusta.core.sinks.sink_base import SinkBase from robusta.core.sinks.sink_config import SinkConfigBase from robusta.core.sinks.slack import SlackSink, SlackSinkConfigWrapper -from robusta.core.sinks.slack.preview import SlackSinkPreview, SlackSinkPreviewConfigWrapper +from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewConfigWrapper +from robusta.core.sinks.slack.preview.slack_sink_preview import SlackSinkPreview from robusta.core.sinks.telegram import TelegramSink, TelegramSinkConfigWrapper from robusta.core.sinks.victorops import VictoropsConfigWrapper, VictoropsSink from robusta.core.sinks.webex import WebexSink, WebexSinkConfigWrapper diff --git a/src/robusta/core/sinks/slack/__init__.py b/src/robusta/core/sinks/slack/__init__.py index 16b32440d..a8fc67d59 100644 --- a/src/robusta/core/sinks/slack/__init__.py +++ b/src/robusta/core/sinks/slack/__init__.py @@ -1,2 +1,4 @@ +from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams, SlackSinkConfigWrapper from robusta.core.sinks.slack.slack_sink import SlackSink -from robusta.core.sinks.slack.slack_sink_params import SlackSinkConfigWrapper, SlackSinkParams + +__all__ = ["SlackSink", "SlackSinkParams", "SlackSinkConfigWrapper"] diff --git a/src/robusta/core/sinks/slack/preview/__init__.py b/src/robusta/core/sinks/slack/preview/__init__.py deleted file mode 100644 index 83ed80126..000000000 --- a/src/robusta/core/sinks/slack/preview/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from robusta.core.sinks.slack.preview.slack_sink_preview import SlackSinkPreview -from robusta.core.sinks.slack.preview.slack_sink_params_preview import SlackSinkPreviewConfigWrapper, SlackSinkPreviewParams diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview.py index 609967dd2..f187a31a4 100644 --- a/src/robusta/core/sinks/slack/preview/slack_sink_preview.py +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview.py @@ -1,11 +1,10 @@ from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewConfigWrapper, SlackSinkPreviewParams from robusta.core.sinks.slack.slack_sink import SlackSink -from robusta.integrations import slack as slack_module class SlackSinkPreview(SlackSink): params: SlackSinkPreviewParams def __init__(self, sink_config: SlackSinkPreviewConfigWrapper, registry): - super().__init__(sink_config.slack_sink_preview, registry, is_preview=True) + super().__init__(sink_config, registry, is_preview=True) diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py index b7b80cdca..3e065b6a8 100644 --- a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py @@ -1,6 +1,6 @@ from robusta.core.sinks.sink_base_params import SinkBaseParams from robusta.core.sinks.sink_config import SinkConfigBase -from robusta.core.sinks.slack import SlackSinkParams +from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams from enum import Enum from typing import Optional, Dict diff --git a/src/robusta/core/sinks/slack/slack_sink.py b/src/robusta/core/sinks/slack/slack_sink.py index 57a82a610..ceca00508 100644 --- a/src/robusta/core/sinks/slack/slack_sink.py +++ b/src/robusta/core/sinks/slack/slack_sink.py @@ -11,9 +11,10 @@ class SlackSink(SinkBase): params: SlackSinkParams def __init__(self, sink_config: SlackSinkConfigWrapper, registry, is_preview=False): - super().__init__(sink_config.slack_sink, registry) - self.slack_channel = sink_config.slack_sink.slack_channel - self.api_key = sink_config.slack_sink.api_key + slack_sink_params = sink_config.get_params() + super().__init__(slack_sink_params, registry) + self.slack_channel = slack_sink_params.slack_channel + self.api_key = slack_sink_params.api_key self.slack_sender = slack_module.SlackSender( self.api_key, self.account_id, self.cluster_name, self.signing_key, self.slack_channel, is_preview ) From 317fbf41834b76f43fb7e74a9f857228c1237764 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Thu, 15 May 2025 18:47:44 +0300 Subject: [PATCH 25/42] refactor send finding to slack --- src/robusta/integrations/slack/sender.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index c8ecbab66..97e1b34e2 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -663,13 +663,26 @@ def send_finding_to_slack( thread_ts: str = None, ) -> str: if self.is_preview: - return self.send_finding_to_slack_preview( + return self.__send_finding_to_slack_preview( finding=finding, sink_params=sink_params, platform_enabled=platform_enabled, thread_ts=thread_ts ) + return self.__send_finding_to_slack( + finding=finding, + sink_params=sink_params, + platform_enabled=platform_enabled, + thread_ts=thread_ts + ) + def __send_finding_to_slack( + self, + finding: Finding, + sink_params: SlackSinkParams, + platform_enabled: bool, + thread_ts: str = None, + ) -> str: blocks: List[BaseBlock] = [] attachment_blocks: List[BaseBlock] = [] @@ -739,7 +752,7 @@ def send_finding_to_slack( thread_ts=thread_ts, ) - def send_finding_to_slack_preview( + def __send_finding_to_slack_preview( self, finding: Finding, sink_params: SlackSinkParams, From bb5d1c2afa03806cfeffc227c8f6e32266784056 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Thu, 15 May 2025 19:01:50 +0300 Subject: [PATCH 26/42] added space --- src/robusta/integrations/slack/sender.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 97e1b34e2..828064d76 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -906,6 +906,7 @@ def __send_finding_to_slack_preview( f"error sending message to slack\ne={e}\ntext={message}\nchannel={slack_channel}" ) return "" + def send_or_update_summary_message( self, group_by_classification_header: List[str], From 181adc19537bf8541deab3fb8c8bab4677f3e21d Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Thu, 15 May 2025 19:16:50 +0300 Subject: [PATCH 27/42] removing unneeded code atm --- .../core/sinks/slack/preview/slack_sink_preview_params.py | 7 ++----- tests/test_slack_integration.py | 1 - 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py index 3e065b6a8..15feb06ae 100644 --- a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py @@ -11,7 +11,6 @@ class SlackTemplateStyle(str, Enum): class SlackSinkPreviewParams(SlackSinkParams): - template_style: SlackTemplateStyle = SlackTemplateStyle.DEFAULT # Use "legacy" for old-style formatting slack_custom_templates: Optional[Dict[str, str]] = None # Template name -> custom template content template_name: Optional[str] = None @@ -20,10 +19,8 @@ def get_effective_template_name(self) -> str: Returns the template name to use for this sink. If template_name is set, use it. Otherwise, use 'legacy.j2' if template_style is legacy, else 'header.j2'. """ - if self.template_name: - return self.template_name - if self.template_style == SlackTemplateStyle.LEGACY: - return "legacy.j2" + if self.slack_custom_templates and len(self.slack_custom_templates) == 1: + return next(iter(self.slack_custom_templates)) return "header.j2" def get_custom_template(self) -> Optional[str]: diff --git a/tests/test_slack_integration.py b/tests/test_slack_integration.py index eb16aaebd..5febeebb3 100755 --- a/tests/test_slack_integration.py +++ b/tests/test_slack_integration.py @@ -57,7 +57,6 @@ def test_slack_template_styles(slack_channel: SlackChannel, template_style, temp name="test_slack", slack_channel=slack_channel.channel_name, api_key="", - template_style=template_style, template_name=template_name, slack_custom_templates={ "custom_template.j2": """ From d4a85a1c8db9f589f9933cdcf5f79dd5dbece578 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Thu, 15 May 2025 19:35:06 +0300 Subject: [PATCH 28/42] refactor --- .../core/sinks/slack/preview/slack_sink_preview_params.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py index 15feb06ae..4fe30dbfa 100644 --- a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py @@ -20,16 +20,15 @@ def get_effective_template_name(self) -> str: Otherwise, use 'legacy.j2' if template_style is legacy, else 'header.j2'. """ if self.slack_custom_templates and len(self.slack_custom_templates) == 1: - return next(iter(self.slack_custom_templates)) + return self.slack_custom_templates.keys()[0] return "header.j2" def get_custom_template(self) -> Optional[str]: """ Returns the custom template string for the effective template name, if it exists. """ - template_name = self.get_effective_template_name() - if self.slack_custom_templates and template_name in self.slack_custom_templates: - return self.slack_custom_templates[template_name] + if self.slack_custom_templates and len(self.slack_custom_templates) == 1: + return self.slack_custom_templates.values()[0] return None From edd0cbd1c4b26e2ec720578c3fb74b356622f53a Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Thu, 15 May 2025 19:37:12 +0300 Subject: [PATCH 29/42] fixing --- .../core/sinks/slack/preview/slack_sink_preview_params.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py index 4fe30dbfa..4e37d43f8 100644 --- a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py @@ -20,7 +20,7 @@ def get_effective_template_name(self) -> str: Otherwise, use 'legacy.j2' if template_style is legacy, else 'header.j2'. """ if self.slack_custom_templates and len(self.slack_custom_templates) == 1: - return self.slack_custom_templates.keys()[0] + return next(iter(self.slack_custom_templates)) return "header.j2" def get_custom_template(self) -> Optional[str]: @@ -28,7 +28,7 @@ def get_custom_template(self) -> Optional[str]: Returns the custom template string for the effective template name, if it exists. """ if self.slack_custom_templates and len(self.slack_custom_templates) == 1: - return self.slack_custom_templates.values()[0] + return next(iter(self.slack_custom_templates)) return None From 4650e18885f17ddf1b7348ef4dfb684abd6b6e1a Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Thu, 15 May 2025 20:21:55 +0300 Subject: [PATCH 30/42] docs update --- docs/configuration/sinks/slack.rst | 144 ++++-------------- .../preview/slack_sink_preview_params.py | 2 +- src/robusta/integrations/slack/sender.py | 3 +- 3 files changed, 32 insertions(+), 117 deletions(-) diff --git a/docs/configuration/sinks/slack.rst b/docs/configuration/sinks/slack.rst index caa9746c7..4818e9eaf 100644 --- a/docs/configuration/sinks/slack.rst +++ b/docs/configuration/sinks/slack.rst @@ -181,140 +181,63 @@ your own. This is not recommended for most companies due to the added complexity When using a custom Slack app, callback buttons are not supported due to complexities in how Slack handles incoming messages. :ref:`Contact us if you need assistance. ` + + Message Templating ------------------------------------------------------------------- Slack messages can be customized using Jinja2 templates. Robusta includes default templates that match the standard format, but you can override them for custom formatting. -To use custom templates, add them to the ``custom_templates`` parameter: +To use custom templates change your `slack_sink` to `slack_sink_preview`, and add your templates to the ``slack_custom_templates`` parameter: .. code-block:: yaml sinksConfig: - - slack_sink: - name: main_slack_sink - slack_channel: "#alerts" - api_key: xoxb-112... - custom_templates: - header.j2: | + - slack_sink_preview: + api_key: xoxb-198... + name: preview_slack_sink + slack_channel: demo-slack-preview + slack_custom_templates: + custom_template.j2: |- { - "type": "section", + "type": "header", "text": { - "type": "mrkdwn", - "text": "{{ status_emoji }} *CUSTOM ALERT: {{ title }}*" + "type": "plain_text", + "text": "Custom Alert Format:\n {{ status_emoji }} [{{ status_text }}] {{ title }}", + "emoji": true } } { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": ":bell: {{ alert_type }} on cluster {{ cluster_name }}" - }, - { - "type": "mrkdwn", - "text": "{{ severity_emoji }} {{ severity }}" - } - ] + "type": "divider" } -Templates use Slack's Block Kit format and must generate valid JSON. Each template block is separated by double newlines (``\n\n``). - -Available template variables: - -+-----------------------------+-------------------------------------------------------------+ -| Variable | Description | -+=============================+=============================================================+ -| ``title`` | The alert title | -+-----------------------------+-------------------------------------------------------------+ -| ``status_text`` | "Firing" or "Resolved" | -+-----------------------------+-------------------------------------------------------------+ -| ``status_emoji`` | "āš ļø" (for firing) or "āœ…" (for resolved) | -+-----------------------------+-------------------------------------------------------------+ -| ``severity`` | Alert severity (e.g., "Warning", "Critical") | -+-----------------------------+-------------------------------------------------------------+ -| ``severity_emoji`` | Emoji for the severity level | -+-----------------------------+-------------------------------------------------------------+ -| ``alert_type`` | "Alert", "K8s Event", or "Notification" | -+-----------------------------+-------------------------------------------------------------+ -| ``cluster_name`` | The name of the cluster | -+-----------------------------+-------------------------------------------------------------+ -| ``platform_enabled`` | Boolean indicating if Robusta platform is enabled | -+-----------------------------+-------------------------------------------------------------+ -| ``include_investigate_link``| Boolean for including investigate link | -+-----------------------------+-------------------------------------------------------------+ -| ``investigate_uri`` | URI for investigation | -+-----------------------------+-------------------------------------------------------------+ -| ``resource_text`` | Resource identifier (e.g., "Pod/namespace/name") | -+-----------------------------+-------------------------------------------------------------+ -| ``resource_emoji`` | Emoji for the resource type | -+-----------------------------+-------------------------------------------------------------+ -| ``finding`` | The complete finding object as JSON | -+-----------------------------+-------------------------------------------------------------+ - -Currently available templates: - -* ``header.j2`` - The header section of alert notificationsTemplate Styles and Customization -------------------------------------------------------------------- - -Slack messages in Robusta can be customized using different template styles and Jinja2 templates. - -Template Styles -~~~~~~~~~~~~~~ - -Robusta supports two built-in template styles: - -1. **default** - Modern JIRA-style formatting (default) -2. **legacy** - Classic formatting matching Robusta's original style - -To select a template style: - -.. code-block:: yaml - - sinksConfig: - - slack_sink: - name: main_slack_sink - slack_channel: "#alerts" - api_key: xoxb-112... - template_style: "legacy" # Use "default" or "legacy" - -Custom Templates -~~~~~~~~~~~~~~~ - -For complete control over message formatting, you can provide custom Jinja2 templates: - -.. code-block:: yaml - - sinksConfig: - - slack_sink: - name: main_slack_sink - slack_channel: "#alerts" - api_key: xoxb-112... - custom_templates: - header.j2: | { "type": "section", - "text": { - "type": "mrkdwn", - "text": "{{ status_emoji }} *CUSTOM ALERT: {{ title }}*" - } - } - - { - "type": "context", - "elements": [ + "fields": [ + { + "type": "mrkdwn", + "text": "*Type:* {{ alert_type }}" + }, { - "type": "mrkdwn", - "text": ":bell: {{ alert_type }} on cluster {{ cluster_name }}" + "type": "mrkdwn", + "text": "*Severity:* {{ severity_emoji }} {{ severity }}" }, { "type": "mrkdwn", - "text": "{{ severity_emoji }} {{ severity }}" + "text": "*Cluster:* {{ cluster_name }}" + } + {% if resource_text %} + , + { + "type": "mrkdwn", + "text": "*Resource:*\n{{ resource_text }}" } + {% endif %} ] } + Templates use Slack's Block Kit format and must generate valid JSON. Each template block is separated by double newlines (``\n\n``). Available template variables: @@ -336,10 +259,6 @@ Available template variables: +-----------------------------+-------------------------------------------------------------+ | ``cluster_name`` | The name of the cluster | +-----------------------------+-------------------------------------------------------------+ -| ``platform_enabled`` | Boolean indicating if Robusta platform is enabled | -+-----------------------------+-------------------------------------------------------------+ -| ``include_investigate_link``| Boolean for including investigate link | -+-----------------------------+-------------------------------------------------------------+ | ``investigate_uri`` | URI for investigation | +-----------------------------+-------------------------------------------------------------+ | ``resource_text`` | Resource identifier (e.g., "Pod/namespace/name") | @@ -349,6 +268,3 @@ Available template variables: | ``finding`` | The complete finding object with all alert data | +-----------------------------+-------------------------------------------------------------+ -Currently available templates: - -* ``header.j2`` - The header section of alert notifications \ No newline at end of file diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py index 4e37d43f8..b0c8b3641 100644 --- a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py @@ -28,7 +28,7 @@ def get_custom_template(self) -> Optional[str]: Returns the custom template string for the effective template name, if it exists. """ if self.slack_custom_templates and len(self.slack_custom_templates) == 1: - return next(iter(self.slack_custom_templates)) + return next(iter(self.slack_custom_templates.values())) return None diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 828064d76..41ff8e883 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -491,8 +491,7 @@ def __create_finding_header_preview( "include_investigate_link": include_investigate_link, "investigate_uri": investigate_uri, "resource_text": resource_text, - "resource_emoji": resource_emoji, - "finding": finding + "resource_emoji": resource_emoji } # Determine the template name to use From ba20164e105ce4a1b7cf288871f12c8701128d23 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Mon, 19 May 2025 15:03:09 +0300 Subject: [PATCH 31/42] updated tests --- .../test_slack_integration_manual.py | 242 ++++++++++-------- 1 file changed, 139 insertions(+), 103 deletions(-) diff --git a/tests/manual_tests/test_slack_integration_manual.py b/tests/manual_tests/test_slack_integration_manual.py index 0f561306c..6a8149530 100644 --- a/tests/manual_tests/test_slack_integration_manual.py +++ b/tests/manual_tests/test_slack_integration_manual.py @@ -25,6 +25,7 @@ ) from robusta.core.model.base_params import ResourceInfo from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams +from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewParams, SlackTemplateStyle from robusta.core.playbooks.internal.ai_integration import ask_holmes # Configure logging - set to DEBUG to see full block details @@ -426,134 +427,169 @@ def main(): logging.info("DEBUG mode enabled - will print detailed block information") logging.info("Run script with DEBUG=false to disable detailed logging") - # Initialize the SlackSender - sender = SlackSender( + # Initialize the standard SlackSender + standard_sender = SlackSender( slack_token=SLACK_TOKEN, account_id="test-account", cluster_name="test-cluster", signing_key="test-signing-key", - slack_channel=SLACK_CHANNEL + slack_channel=SLACK_CHANNEL, + is_preview=False ) - # Create the SlackSinkParams - sink_params = SlackSinkParams( - name="test-sink", + # Initialize the preview SlackSender + preview_sender = SlackSender( + slack_token=SLACK_TOKEN, + account_id="test-account", + cluster_name="test-cluster", + signing_key="test-signing-key", + slack_channel=SLACK_CHANNEL, + is_preview=True + ) + + # Create a test finding + finding = create_test_finding("Test Alert", FindingSeverity.HIGH) + + # Add markdown blocks with various content + finding.add_enrichment([ + MarkdownBlock("## Alert Details"), + MarkdownBlock("This is a test alert with *bold* and _italic_ text"), + MarkdownBlock("`Code snippet: kubectl get pods -n default`"), + MarkdownBlock("> Important note: This is a test alert"), + MarkdownBlock("• Bullet point 1\n• Bullet point 2\n• Bullet point 3"), + DividerBlock(), + MarkdownBlock("### Resource Information"), + MarkdownBlock("The following resources are affected by this alert:"), + ]) + + # Add a table with labels + finding.add_enrichment([ + TableBlock( + rows=[ + ["app", "test-app"], + ["environment", "test"], + ["severity", "high"], + ["namespace", "default"], + ["pod", "test-pod"], + ["container", "test-container"], + ["node", "test-node"], + ["cluster", "test-cluster"] + ], + headers=["Label", "Value"], + table_name="Resource Labels", + metadata={"format": "vertical"} + ) + ]) + + # Add file blocks with sample text + finding.add_enrichment([ + FileBlock( + filename="pod-logs.txt", + contents=b"""2024-03-23 10:15:23 INFO Starting application +2024-03-23 10:15:24 INFO Loading configuration +2024-03-23 10:15:25 ERROR Failed to connect to database +2024-03-23 10:15:26 WARN Retrying connection... +2024-03-23 10:15:27 ERROR Connection timeout""" + ), + FileBlock( + filename="config.yaml", + contents=b"""apiVersion: v1 +kind: Pod +metadata: + name: test-pod + namespace: default +spec: + containers: + - name: test-container + image: nginx:latest + resources: + requests: + memory: "64Mi" + cpu: "250m" + limits: + memory: "128Mi" + cpu: "500m" +""" + ) + ]) + + # Test 1: Standard Slack Sink with default template + logging.info("Test 1: Standard Slack Sink with default template") + standard_params = SlackSinkParams( + name="standard-sink", slack_channel=SLACK_CHANNEL, api_key=SLACK_TOKEN, investigate_link=True, prefer_redirect_to_platform=False, max_log_file_limit_kb=1000 ) + standard_sender.send_finding_to_slack(finding, standard_params, platform_enabled=True) - # Create a SlackSinkParams with legacy style - legacy_template_params = SlackSinkParams( - name="legacy-template-sink", + # Test 2: Slack Preview Sink with default template + logging.info("Test 2: Slack Preview Sink with default template") + preview_params = SlackSinkPreviewParams( + name="preview-sink", slack_channel=SLACK_CHANNEL, api_key=SLACK_TOKEN, investigate_link=True, prefer_redirect_to_platform=False, - max_log_file_limit_kb=1000, - template_style="legacy" # Use the legacy template style + max_log_file_limit_kb=1000 ) - - # Create a SlackSinkParams with a custom template - custom_template_params = SlackSinkParams( - name="custom-template-sink", + preview_sender.send_finding_to_slack(finding, preview_params, platform_enabled=True) + + # Test 3: Slack Preview Sink with custom template + logging.info("Test 3: Slack Preview Sink with custom template") + custom_template = """ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Custom Alert Format:\n {{ status_emoji }} [{{ status_text }}] {{ title }}", + "emoji": true + } + } + + { + "type": "divider" + } + + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Type:* {{ alert_type }}" + }, + { + "type": "mrkdwn", + "text": "*Severity:* {{ severity_emoji }} {{ severity }}" + }, + { + "type": "mrkdwn", + "text": "*Cluster:* {{ cluster_name }}" + } + {% if resource_text %} + , + { + "type": "mrkdwn", + "text": "*Resource:*\n{{ resource_text }}" + } + {% endif %} + ] + } + """ + custom_params = SlackSinkPreviewParams( + name="custom-sink", slack_channel=SLACK_CHANNEL, api_key=SLACK_TOKEN, investigate_link=True, prefer_redirect_to_platform=False, max_log_file_limit_kb=1000, - template_name="custom.j2", - slack_custom_templates={ - "custom.j2": """ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "{{ status_emoji }} *CUSTOM TEMPLATE: {{ title }}*" - } - } - - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": ":rotating_light: {{ alert_type }} on cluster {{ cluster_name }}" - }, - { - "type": "mrkdwn", - "text": "{{ severity_emoji }} {{ severity }} severity" - } - {% if resource_text %} - ,{ - "type": "mrkdwn", - "text": "{{ resource_emoji }} {{ resource_text }}" - } - {% endif %} - ] - } - """ - } + slack_custom_templates={"custom.j2": custom_template} ) + preview_sender.send_finding_to_slack(finding, custom_params, platform_enabled=True) - # Create all findings - crash_loop_finding = create_crash_loop_finding() - node_saturation_finding = create_node_saturation_finding() - datadog_agent_crash_finding = create_datadog_agent_crash_finding() - cpu_throttling_finding = create_cpu_throttling_finding() - - # Send findings to Slack with standard templates - logging.info(f"Sending crash loop finding to Slack channel (DEFAULT TEMPLATE): {SLACK_CHANNEL}...") - sender.send_finding_to_slack(crash_loop_finding, sink_params, platform_enabled=True) - - # Send findings to Slack with legacy template style - logging.info(f"Sending node saturation finding to Slack channel with LEGACY TEMPLATE: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(node_saturation_finding, legacy_template_params, platform_enabled=True) - - # Send findings to Slack with custom templates - logging.info(f"Sending datadog agent crash finding to Slack channel with CUSTOM TEMPLATE: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(datadog_agent_crash_finding, custom_template_params, platform_enabled=True) - - logging.info(f"Sending CPU throttling finding to Slack channel with CUSTOM TEMPLATE: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(cpu_throttling_finding, custom_template_params, platform_enabled=True) - - logging.info(f"Test messages sent successfully to Slack channel: {SLACK_CHANNEL}") - - # Uncomment the below code to send the original test findings - """ - # Create test findings with different severities - finding_info = create_test_finding("Test INFO Alert", FindingSeverity.INFO) - finding_low = create_test_finding("Test LOW Alert", FindingSeverity.LOW) - finding_medium = create_test_finding("Test MEDIUM Alert", FindingSeverity.MEDIUM) - finding_high = create_test_finding("Test HIGH Alert", FindingSeverity.HIGH) - - # Add different types of content to each finding - add_basic_blocks(finding_info) - add_complex_table(finding_low) - - # Combine basic and complex content for medium severity - add_basic_blocks(finding_medium) - add_complex_table(finding_medium) - - # Add AI callback for high severity - add_basic_blocks(finding_high) - create_holmes_callback(finding_high) - - # Send all test findings to Slack - logging.info(f"Sending INFO finding to Slack channel: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(finding_info, sink_params, platform_enabled=True) - - logging.info(f"Sending LOW finding to Slack channel: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(finding_low, sink_params, platform_enabled=True) - - logging.info(f"Sending MEDIUM finding to Slack channel: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(finding_medium, sink_params, platform_enabled=True) - - logging.info(f"Sending HIGH finding with Holmes callback to Slack channel: {SLACK_CHANNEL}...") - sender.send_finding_to_slack(finding_high, sink_params, platform_enabled=True) - """ + logging.info(f"All test messages sent successfully to Slack channel: {SLACK_CHANNEL}") if __name__ == "__main__": From c468f5aa742d275018e38ba220fa37248c04b941 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Wed, 21 May 2025 11:26:28 +0300 Subject: [PATCH 32/42] bugfixes and header support --- docs/configuration/sinks/slack.rst | 8 ++++++ .../preview/slack_sink_preview_params.py | 17 ++++++----- .../sinks/slack/templates/template_loader.py | 13 +-------- src/robusta/integrations/slack/sender.py | 28 ++++++++++++++----- .../test_slack_integration_manual.py | 16 +++++++---- 5 files changed, 50 insertions(+), 32 deletions(-) diff --git a/docs/configuration/sinks/slack.rst b/docs/configuration/sinks/slack.rst index 4818e9eaf..13fcb10a6 100644 --- a/docs/configuration/sinks/slack.rst +++ b/docs/configuration/sinks/slack.rst @@ -263,8 +263,16 @@ Available template variables: +-----------------------------+-------------------------------------------------------------+ | ``resource_text`` | Resource identifier (e.g., "Pod/namespace/name") | +-----------------------------+-------------------------------------------------------------+ +| ``subject_kind`` | The Kubernetes resource kind (e.g., "Pod", "Deployment") | ++-----------------------------+-------------------------------------------------------------+ +| ``subject_namespace`` | The Kubernetes namespace | ++-----------------------------+-------------------------------------------------------------+ +| ``subject_name`` | The name of the Kubernetes resource | ++-----------------------------+-------------------------------------------------------------+ | ``resource_emoji`` | Emoji for the resource type | +-----------------------------+-------------------------------------------------------------+ +| ``mention`` | Any @mentions extracted from the title | ++-----------------------------+-------------------------------------------------------------+ | ``finding`` | The complete finding object with all alert data | +-----------------------------+-------------------------------------------------------------+ diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py index b0c8b3641..9ca1c0a92 100644 --- a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py @@ -2,7 +2,7 @@ from robusta.core.sinks.sink_config import SinkConfigBase from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams from enum import Enum -from typing import Optional, Dict +from typing import Optional, Dict, Any class SlackTemplateStyle(str, Enum): @@ -24,12 +24,15 @@ def get_effective_template_name(self) -> str: return "header.j2" def get_custom_template(self) -> Optional[str]: - """ - Returns the custom template string for the effective template name, if it exists. - """ - if self.slack_custom_templates and len(self.slack_custom_templates) == 1: - return next(iter(self.slack_custom_templates.values())) - return None + """Get the custom template for the current template style""" + if not self.slack_custom_templates: + return None + + template_name = self.get_effective_template_name() + if template_name not in self.slack_custom_templates: + return None + + return self.slack_custom_templates[template_name] class SlackSinkPreviewConfigWrapper(SinkConfigBase): diff --git a/src/robusta/core/sinks/slack/templates/template_loader.py b/src/robusta/core/sinks/slack/templates/template_loader.py index 9d47bff97..ab19e29af 100644 --- a/src/robusta/core/sinks/slack/templates/template_loader.py +++ b/src/robusta/core/sinks/slack/templates/template_loader.py @@ -1,7 +1,6 @@ import json import logging import os -import re from typing import Any, Dict, List from jinja2 import Environment, FileSystemLoader, Template, select_autoescape @@ -10,15 +9,6 @@ TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__))) -def escape_raw_newlines_in_json_strings(raw_json: str) -> str: - def fix_string(match): - s = match.group(1) - return s.replace("\n", "\\n") # Escape raw newlines in strings - - STRING_LITERAL_RE = re.compile(r'("(?:\\.|[^"\\])*")', flags=re.DOTALL) - return STRING_LITERAL_RE.sub(fix_string, raw_json) - - class SlackTemplateLoader: """ Loads and renders Jinja2 templates for Slack messages. @@ -71,9 +61,8 @@ def render_to_blocks(self, template: Template, context: Dict[str, Any]) -> List[ if not block_str.strip(): continue try: - block_str_fixed = escape_raw_newlines_in_json_strings(block_str) # Try to parse as JSON, but if it fails, log and skip - block = json.loads(block_str_fixed) + block = json.loads(block_str) blocks.append(block) except json.JSONDecodeError as e: logging.exception(f"Error parsing JSON from template output: {e}") diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 41ff8e883..65316d0e6 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -94,6 +94,13 @@ def __init__(self, slack_token: str, account_id: str, cluster_name: str, signing logging.error(f"Cannot connect to Slack API: {e}") raise e + def __slack_preview_sanitize_string(self, text: str) -> str: + """ + Properly sanitize a string for JSON by escaping newlines. + First unescapes any already escaped newlines, then escapes all newlines. + """ + return text.replace("\\n", "\n").replace("\n", "\\n") + def __get_action_block_for_choices(self, sink: str, choices: Dict[str, CallbackChoice] = None): if choices is None: return [] @@ -449,9 +456,12 @@ def __create_finding_header_preview( alert_type = "Notification" # Prepare resource text and emoji if available - resource_text = "" resource_emoji = ":package:" + subject_kind = "" + subject_namespace = "" + subject_name = "" + resource_id = "" if finding.subject: subject_kind = finding.subject.subject_type.value subject_namespace = finding.subject.namespace @@ -474,13 +484,13 @@ def __create_finding_header_preview( # Format as Kind/Namespace/Name if subject_namespace: - resource_text = f"{subject_kind}/{subject_namespace}/{subject_name}" + resource_id = f"{subject_kind}/{subject_namespace}/{subject_name}" else: - resource_text = f"{subject_kind}/{subject_name}" + resource_id = f"{subject_kind}/{subject_name}" # Prepare template context template_context = { - "title": title, + "title": self.__slack_preview_sanitize_string(title), "status_text": status_text, "status_emoji": status_emoji, "severity": sev.name.capitalize(), @@ -489,9 +499,13 @@ def __create_finding_header_preview( "cluster_name": self.cluster_name, "platform_enabled": platform_enabled, "include_investigate_link": include_investigate_link, - "investigate_uri": investigate_uri, - "resource_text": resource_text, - "resource_emoji": resource_emoji + "investigate_uri": investigate_uri if investigate_uri else "", + "resource_text": resource_id, + "subject_kind": subject_kind, + "subject_namespace": subject_namespace, + "subject_name": subject_name, + "resource_emoji": resource_emoji, + "mention": mention, } # Determine the template name to use diff --git a/tests/manual_tests/test_slack_integration_manual.py b/tests/manual_tests/test_slack_integration_manual.py index 6a8149530..7c5b57856 100644 --- a/tests/manual_tests/test_slack_integration_manual.py +++ b/tests/manual_tests/test_slack_integration_manual.py @@ -41,6 +41,8 @@ SLACK_TOKEN = os.environ.get("SLACK_TOKEN", "xoxb-your-actual-token-here") # The Slack channel to send messages to SLACK_CHANNEL = os.environ.get("SLACK_CHANNEL", "test-robusta") +# Optional Slack user ID to mention in test messages (e.g., "U1234567890") +SLACK_MENTION = os.environ.get("SLACK_MENTION", "") # In a real scenario, we'd use @action decorator, but for testing @@ -50,6 +52,10 @@ # Create mock data for the Finding def create_test_finding(title: str, severity: FindingSeverity = FindingSeverity.INFO) -> Finding: """Create a test finding with the given title and severity""" + # Add mention to title if SLACK_MENTION is set + if SLACK_MENTION: + title = f"<@{SLACK_MENTION}> {title}" + # Create a subject first with the correct type subject = FindingSubject( name="test-pod", @@ -539,12 +545,11 @@ def main(): # Test 3: Slack Preview Sink with custom template logging.info("Test 3: Slack Preview Sink with custom template") - custom_template = """ - { + custom_template = """{ "type": "header", "text": { "type": "plain_text", - "text": "Custom Alert Format:\n {{ status_emoji }} [{{ status_text }}] {{ title }}", + "text": "Custom Alert Format:\\n {{ status_emoji }} [{{ status_text }}] {{ title }}", "emoji": true } } @@ -572,12 +577,11 @@ def main(): , { "type": "mrkdwn", - "text": "*Resource:*\n{{ resource_text }}" + "text": "*Resource:*\\n{{ resource_text }}" } {% endif %} ] - } - """ + }""" custom_params = SlackSinkPreviewParams( name="custom-sink", slack_channel=SLACK_CHANNEL, From fa8a68e388441c106ca2ca295df209f6a497807a Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Wed, 21 May 2025 11:35:02 +0300 Subject: [PATCH 33/42] fixed mentions --- src/robusta/core/sinks/slack/templates/header.j2 | 2 +- tests/manual_tests/test_slack_integration_manual.py | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/robusta/core/sinks/slack/templates/header.j2 b/src/robusta/core/sinks/slack/templates/header.j2 index 82ea7b1aa..9b1201ea3 100644 --- a/src/robusta/core/sinks/slack/templates/header.j2 +++ b/src/robusta/core/sinks/slack/templates/header.j2 @@ -6,7 +6,7 @@ "type": "section", "text": { "type": "mrkdwn", - "text": "{{ status_emoji }} *[{{ status_text }}] {% if platform_enabled and include_investigate_link %}<{{ investigate_uri }}|{{ title }}>{% else %}{{ title }}{% endif %}*" + "text": "{{ status_emoji }} *[{{ status_text }}] {% if platform_enabled and include_investigate_link %}<{{ investigate_uri }}|{{ title }}>{% else %}{{ title }}{% endif %}*{% if mention %} {{ mention }}{% endif %}" } } diff --git a/tests/manual_tests/test_slack_integration_manual.py b/tests/manual_tests/test_slack_integration_manual.py index 7c5b57856..3a2bb7ee8 100644 --- a/tests/manual_tests/test_slack_integration_manual.py +++ b/tests/manual_tests/test_slack_integration_manual.py @@ -549,11 +549,19 @@ def main(): "type": "header", "text": { "type": "plain_text", - "text": "Custom Alert Format:\\n {{ status_emoji }} [{{ status_text }}] {{ title }}", + "text": "Custom Alert Format", "emoji": true } } + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{{ status_emoji }} *[{{ status_text }}] {{ title }}*{% if mention %} {{ mention }}{% endif %}" + } + } + { "type": "divider" } From fcad8481c03b43cea2aab75e7610ce2c81e1c495 Mon Sep 17 00:00:00 2001 From: avi robusta Date: Wed, 21 May 2025 14:21:50 +0300 Subject: [PATCH 34/42] added channel override test rename test preview pytest fix test fix test fix update slack_utils pytest changes attempting to debug slack test fixing pytest preview sink --- .../test_slack_integration_manual.py | 45 ++++++ tests/test_slack_integration.py | 78 ---------- tests/test_slack_preview.py | 147 ++++++++++++++++++ tests/utils/slack_utils.py | 5 + 4 files changed, 197 insertions(+), 78 deletions(-) delete mode 100755 tests/test_slack_integration.py create mode 100755 tests/test_slack_preview.py diff --git a/tests/manual_tests/test_slack_integration_manual.py b/tests/manual_tests/test_slack_integration_manual.py index 3a2bb7ee8..893af5162 100644 --- a/tests/manual_tests/test_slack_integration_manual.py +++ b/tests/manual_tests/test_slack_integration_manual.py @@ -41,6 +41,8 @@ SLACK_TOKEN = os.environ.get("SLACK_TOKEN", "xoxb-your-actual-token-here") # The Slack channel to send messages to SLACK_CHANNEL = os.environ.get("SLACK_CHANNEL", "test-robusta") +# Optional channel override for testing label-based routing +SLACK_OVERRIDE_CHANNEL = os.environ.get("SLACK_OVERRIDE_CHANNEL", "") # Optional Slack user ID to mention in test messages (e.g., "U1234567890") SLACK_MENTION = os.environ.get("SLACK_MENTION", "") @@ -601,6 +603,49 @@ def main(): ) preview_sender.send_finding_to_slack(finding, custom_params, platform_enabled=True) + # Test 4: Channel Override Test (only if SLACK_OVERRIDE_CHANNEL is set) + if SLACK_OVERRIDE_CHANNEL: + logging.info(f"Test 4: Channel Override Test using label-based routing to {SLACK_OVERRIDE_CHANNEL}") + + # Create a new finding with a specific label for channel override + override_finding = create_test_finding("Channel Override Test Alert", FindingSeverity.HIGH) + + # Add the slack label to the subject's labels + override_finding.subject.labels["slack"] = SLACK_OVERRIDE_CHANNEL + + # Add some specific content for the override test + override_finding.add_enrichment([ + MarkdownBlock("## Channel Override Test"), + MarkdownBlock("This alert is being routed using label-based channel override"), + TableBlock( + rows=[ + ["Default Channel", SLACK_CHANNEL], + ["Override Channel", SLACK_OVERRIDE_CHANNEL], + ["Override Method", "Label-based (labels.slack)"], + ["Test Type", "Channel Override"], + ["Timestamp", datetime.now().strftime("%Y-%m-%d %H:%M:%S")] + ], + headers=["Property", "Value"], + table_name="Override Information", + metadata={"format": "vertical"} + ) + ]) + + # Create override params with channel_override set to use the slack label + override_params = SlackSinkPreviewParams( + name="override-sink", + slack_channel=SLACK_CHANNEL, # This will be the fallback channel + api_key=SLACK_TOKEN, + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000, + channel_override="labels.slack" # This will read from the slack label + ) + + # Send using the preview sender (it will use the override channel from the label) + preview_sender.send_finding_to_slack(override_finding, override_params, platform_enabled=True) + logging.info(f"Channel override test message sent to {SLACK_OVERRIDE_CHANNEL}") + logging.info(f"All test messages sent successfully to Slack channel: {SLACK_CHANNEL}") diff --git a/tests/test_slack_integration.py b/tests/test_slack_integration.py deleted file mode 100755 index 5febeebb3..000000000 --- a/tests/test_slack_integration.py +++ /dev/null @@ -1,78 +0,0 @@ -# This file is being moved to tests/test_slack_templates.py and refactored to match the style of test_slack.py. -# The script/main logic and unused code are removed. - -import pytest -from robusta.core.reporting.base import Finding, FindingSeverity, FindingSource -from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams, SlackTemplateStyle -from robusta.core.reporting.blocks import MarkdownBlock -from robusta.integrations.slack.sender import SlackSender -from tests.config import CONFIG -from tests.utils.slack_utils import SlackChannel - -TEST_ACCOUNT = "test account" -TEST_CLUSTER = "test cluster" -TEST_KEY = "test key" - -@pytest.mark.parametrize("template_style,custom_template,expected_phrases", [ - ( - SlackTemplateStyle.DEFAULT, - None, - [ - "[Firing]", # status block - ":bell: Type: Alert", # context block - "Severity: Info", - ":globe_with_meridians: Cluster: test cluster" - ] - ), - ( - SlackTemplateStyle.LEGACY, - None, - [ - "Prometheus Alert Firing", # header block - "*Source:* `test cluster`" - ] - ), - ( - SlackTemplateStyle.DEFAULT, - "custom_template.j2", - [ - "CUSTOM TEMPLATE:", - ] - ) -]) -def test_slack_template_styles(slack_channel: SlackChannel, template_style, template_name, expected_phrases): - slack_sender = SlackSender( - CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name - ) - finding = Finding( - title="Test Template Style", - aggregation_key="test-template-style", - severity=FindingSeverity.INFO, - source=FindingSource.PROMETHEUS, - description="Testing template style rendering" - ) - finding.add_enrichment([MarkdownBlock("This is a test block.")]) - - slack_params = SlackSinkParams( - name="test_slack", - slack_channel=slack_channel.channel_name, - api_key="", - template_name=template_name, - slack_custom_templates={ - "custom_template.j2": """ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "CUSTOM TEMPLATE: {{ title }}" - } - } - """, - }) - - slack_sender.send_finding_to_slack(finding, slack_params, False) - latest_message = slack_channel.get_latest_message() - for phrase in expected_phrases: - assert phrase in latest_message - assert "Test Template Style" in latest_message - assert "This is a test block." in latest_message \ No newline at end of file diff --git a/tests/test_slack_preview.py b/tests/test_slack_preview.py new file mode 100755 index 000000000..954db2a03 --- /dev/null +++ b/tests/test_slack_preview.py @@ -0,0 +1,147 @@ +# This file is being moved to tests/test_slack_templates.py and refactored to match the style of test_slack.py. +# The script/main logic and unused code are removed. + +import pytest +from robusta.core.reporting.base import Finding, FindingSeverity, FindingSource, FindingSubject, FindingSubjectType +from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewParams +from robusta.core.reporting.blocks import MarkdownBlock +from robusta.integrations.slack.sender import SlackSender +from tests.config import CONFIG +from tests.utils.slack_utils import SlackChannel + +TEST_ACCOUNT = "test account" +TEST_CLUSTER = "test cluster" +TEST_KEY = "test key" + +def extract_text_from_blocks(message): + """Extract all text content from Slack message blocks and attachments""" + text_parts = [] + + # Extract text from main blocks + if 'blocks' in message: + for block in message['blocks']: + if block.get('type') == 'section' and 'text' in block: + text_parts.append(block['text'].get('text', '')) + + # Extract text from attachments + if 'attachments' in message: + for attachment in message['attachments']: + if 'blocks' in attachment: + for block in attachment['blocks']: + if block.get('type') == 'section' and 'text' in block: + text_parts.append(block['text'].get('text', '')) + + return ' '.join(text_parts) + +def test_slack_preview_default_template(slack_channel: SlackChannel): + slack_sender = SlackSender( + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, is_preview=True + ) + + # Create a subject for the finding + subject = FindingSubject( + name="test-pod", + namespace="default", + subject_type=FindingSubjectType.from_kind("pod"), + labels={ + "app": "test-app", + "environment": "test" + } + ) + + finding = Finding( + title="Test Preview Template", + aggregation_key="test-preview-template", + severity=FindingSeverity.INFO, + source=FindingSource.PROMETHEUS, + description="Testing preview template rendering", + subject=subject + ) + + # Add enrichments that will be included in the preview + finding.add_enrichment([ + MarkdownBlock("This is a test preview block."), + MarkdownBlock("*Additional Information:*"), + MarkdownBlock("• Test point 1\n• Test point 2") + ]) + + preview_params = SlackSinkPreviewParams( + name="test_preview", + slack_channel=slack_channel.channel_name, + api_key="", + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000 + ) + + slack_sender.send_finding_to_slack(finding, preview_params, platform_enabled=True) + latest_message = slack_channel.get_complete_latest_message() + message_text = extract_text_from_blocks(latest_message) + + # Check for key elements in the preview message + assert "Test Preview Template" in message_text + assert "This is a test preview block." in message_text + assert "Additional Information" in message_text + assert "Test point 1" in message_text + assert "Test point 2" in message_text + +def test_slack_preview_custom_template(slack_channel: SlackChannel): + slack_sender = SlackSender( + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, is_preview=True + ) + + subject = FindingSubject( + name="test-pod", + namespace="default", + subject_type=FindingSubjectType.from_kind("pod"), + labels={ + "app": "test-app", + "environment": "test" + } + ) + + finding = Finding( + title="Test Custom Preview Template", + aggregation_key="test-custom-preview-template", + severity=FindingSeverity.INFO, + source=FindingSource.PROMETHEUS, + description="Testing custom preview template rendering", + subject=subject + ) + + finding.add_enrichment([ + MarkdownBlock("This is a test custom preview block."), + MarkdownBlock("*Custom Content:*"), + MarkdownBlock("• Custom point 1\n• Custom point 2") + ]) + + # Custom template that includes both title and content + custom_template = """{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "CUSTOM PREVIEW: {{ title }}\\n\\n{{ description }}" + } + }""" + + preview_params = SlackSinkPreviewParams( + name="test_custom_preview", + slack_channel=slack_channel.channel_name, + api_key="", + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000, + slack_custom_templates={"custom.j2": custom_template} + ) + + slack_sender.send_finding_to_slack(finding, preview_params, platform_enabled=True) + latest_message = slack_channel.get_complete_latest_message() + message_text = extract_text_from_blocks(latest_message) + + # Check for custom template elements + assert "CUSTOM PREVIEW: Test Custom Preview Template" in message_text + assert "Testing custom preview template rendering" in message_text + assert "This is a test custom preview block." in message_text + assert "Custom Content" in message_text + assert "Custom point 1" in message_text + assert "Custom point 2" in message_text diff --git a/tests/utils/slack_utils.py b/tests/utils/slack_utils.py index b28979a16..4890eeffb 100644 --- a/tests/utils/slack_utils.py +++ b/tests/utils/slack_utils.py @@ -22,6 +22,11 @@ def get_latest_message(self): messages = results["messages"] return messages[0]["text"] + def get_complete_latest_message(self): + results = self.client.conversations_history(channel=self.channel_id) + messages = results["messages"] + return messages[0] + @staticmethod def _create_or_join_channel(client: WebClient, channel_name: str) -> str: """Creates or joins the specified slack channel and returns its channel_id""" From b1dda3b94a42493e5369ead1bbe5fca40e5be0b0 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Wed, 21 May 2025 15:21:06 +0300 Subject: [PATCH 35/42] refactoring, removing useless changes --- src/robusta/core/reporting/base.py | 36 --------- src/robusta/core/sinks/slack/__init__.py | 1 + .../preview/slack_sink_preview_params.py | 34 ++++----- .../core/sinks/slack/slack_sink_params.py | 1 + .../core/sinks/slack/templates/README.md | 73 ------------------- .../core/sinks/slack/templates/__init__.py | 1 - .../core/sinks/slack/templates/legacy.j2 | 31 -------- .../sinks/slack/templates/template_loader.py | 23 +----- src/robusta/integrations/slack/sender.py | 22 +++--- .../test_slack_integration_manual.py | 2 +- 10 files changed, 34 insertions(+), 190 deletions(-) delete mode 100644 src/robusta/core/sinks/slack/templates/README.md delete mode 100644 src/robusta/core/sinks/slack/templates/__init__.py delete mode 100644 src/robusta/core/sinks/slack/templates/legacy.j2 diff --git a/src/robusta/core/reporting/base.py b/src/robusta/core/reporting/base.py index b7dade26a..9a53d0e43 100644 --- a/src/robusta/core/reporting/base.py +++ b/src/robusta/core/reporting/base.py @@ -149,14 +149,6 @@ def __init__( def __str__(self): return f"annotations: {self.annotations} Enrichment: {self.blocks} " - - def to_dict(self): - return { - "blocks": [block.dict() for block in self.blocks], - "annotations": self.annotations, - "enrichment_type": self.enrichment_type, - "title": self.title, - } class FilterableScopeMatcher(BaseScopeMatcher): @@ -412,31 +404,3 @@ def __calculate_fingerprint(subject: FindingSubject, source: FindingSource, aggr # if not, generate with logic similar to alertmanager s = f"{subject.subject_type},{subject.name},{subject.namespace},{subject.node},{source.value}{aggregation_key}" return hashlib.sha256(s.encode()).hexdigest() - - def to_json(self): - return { - "title": self.title, - "aggregation_key": self.aggregation_key, - "severity": self.severity.name, - "source": self.source.name, - "description": self.description, - "subject": { - "name": self.subject.name, - "subject_type": self.subject.subject_type.value, - "namespace": self.subject.namespace, - "node": self.subject.node, - "container": self.subject.container, - "labels": self.subject.labels, - "annotations": self.subject.annotations, - }, - "finding_type": self.finding_type.name, - "failure": self.failure, - "creation_date": self.creation_date, - "fingerprint": self.fingerprint, - "starts_at": self.starts_at, - "ends_at": self.ends_at, - "add_silence_url": self.add_silence_url, - "silence_labels": self.silence_labels, - "enrichments": [enrichment.to_dict() for enrichment in self.enrichments], - "links": [link.dict() for link in self.links], - } diff --git a/src/robusta/core/sinks/slack/__init__.py b/src/robusta/core/sinks/slack/__init__.py index a8fc67d59..d20bde126 100644 --- a/src/robusta/core/sinks/slack/__init__.py +++ b/src/robusta/core/sinks/slack/__init__.py @@ -1,4 +1,5 @@ from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams, SlackSinkConfigWrapper from robusta.core.sinks.slack.slack_sink import SlackSink +# to prevent circular imports in SlackSender, SlackSinkParams and SlackSinkPreviewParams __all__ = ["SlackSink", "SlackSinkParams", "SlackSinkConfigWrapper"] diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py index 9ca1c0a92..f3cc647ce 100644 --- a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py @@ -1,38 +1,36 @@ from robusta.core.sinks.sink_base_params import SinkBaseParams from robusta.core.sinks.sink_config import SinkConfigBase from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams -from enum import Enum -from typing import Optional, Dict, Any - - -class SlackTemplateStyle(str, Enum): - DEFAULT = "default" - LEGACY = "legacy" +from typing import Optional, Dict +from pydantic import validator class SlackSinkPreviewParams(SlackSinkParams): + #TODO: improve the SlackSinkPreviewParams so the slack_custom_templates can be defined once globally and + # only a template name needs to be passed to each channel in the config slack_custom_templates: Optional[Dict[str, str]] = None # Template name -> custom template content - template_name: Optional[str] = None - def get_effective_template_name(self) -> str: + @validator('slack_custom_templates') + def check_one_item(cls, v): + if v is not None and len(v) != 1: + raise ValueError("slack_custom_templates must contain exactly one key-value pair") + return v + + def get_template_name(self) -> str: """ - Returns the template name to use for this sink. If template_name is set, use it. - Otherwise, use 'legacy.j2' if template_style is legacy, else 'header.j2'. + Returns the template name to use for this sink. If slack_custom_templates is set, use the first one. + Otherwise, use 'header.j2'. """ - if self.slack_custom_templates and len(self.slack_custom_templates) == 1: + if self.slack_custom_templates: return next(iter(self.slack_custom_templates)) return "header.j2" def get_custom_template(self) -> Optional[str]: - """Get the custom template for the current template style""" + """Get the custom template if defined""" if not self.slack_custom_templates: return None - template_name = self.get_effective_template_name() - if template_name not in self.slack_custom_templates: - return None - - return self.slack_custom_templates[template_name] + return next(iter(self.slack_custom_templates.values())) class SlackSinkPreviewConfigWrapper(SinkConfigBase): diff --git a/src/robusta/core/sinks/slack/slack_sink_params.py b/src/robusta/core/sinks/slack/slack_sink_params.py index b9145453e..2badde2a6 100644 --- a/src/robusta/core/sinks/slack/slack_sink_params.py +++ b/src/robusta/core/sinks/slack/slack_sink_params.py @@ -1,6 +1,7 @@ from robusta.core.sinks.sink_base_params import SinkBaseParams from robusta.core.sinks.sink_config import SinkConfigBase from robusta.core.sinks.common import ChannelTransformer + from typing import Optional from pydantic import validator diff --git a/src/robusta/core/sinks/slack/templates/README.md b/src/robusta/core/sinks/slack/templates/README.md deleted file mode 100644 index 71ab4255d..000000000 --- a/src/robusta/core/sinks/slack/templates/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# Slack Message Templates - -This directory contains the Jinja2 templates used to render Slack messages. - -## How Templates Work - -Slack messages are rendered using [Jinja2](https://jinja.palletsprojects.com/) templates. Each template produces one or more Slack Block Kit blocks in JSON format. - -Templates are separated by double newlines (`\n\n`) to indicate separate blocks. Each block must be valid JSON that conforms to the [Slack Block Kit](https://api.slack.com/block-kit) format. - -## Available Templates - -- `header.j2`: The header section of alert notifications, including title and metadata - -## Customizing Templates - -Users can customize templates in the Robusta configuration by providing their own template content: - -```yaml -sinks: - slack: - slack_sink: - name: slack - slack_channel: "#alerts" - api_key: "${SLACK_TOKEN}" - custom_templates: - header.j2: | - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "{{ status_emoji }} *CUSTOM ALERT: {{ title }}*" - } - } - - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": ":bell: {{ alert_type }} on cluster {{ cluster_name }}" - }, - { - "type": "mrkdwn", - "text": "{{ severity_emoji }} {{ severity }}" - } - ] - } -``` - -## Template Variables - -Each template has access to different variables. Here are the variables available in the `header.j2` template: - -| Variable | Description | -|----------|-------------| -| `title` | The alert title | -| `status_text` | "Firing" or "Resolved" | -| `status_emoji` | "āš ļø" (for firing) or "āœ…" (for resolved) | -| `severity` | Alert severity (e.g., "Warning", "Critical") | -| `severity_emoji` | Emoji for the severity level | -| `alert_type` | "Alert", "K8s Event", or "Notification" | -| `cluster_name` | The name of the cluster | -| `platform_enabled` | Boolean indicating if Robusta platform is enabled | -| `include_investigate_link` | Boolean indicating if investigate link should be included | -| `investigate_uri` | URI for investigation | -| `resource_text` | Resource identifier (e.g., "Pod/namespace/name") | -| `resource_emoji` | Emoji for the resource type | -| `finding` | The complete finding object as JSON | - -## Fallback Behavior - -If Jinja2 is not available, a built-in fallback implementation will be used that produces the same output as the default template. \ No newline at end of file diff --git a/src/robusta/core/sinks/slack/templates/__init__.py b/src/robusta/core/sinks/slack/templates/__init__.py deleted file mode 100644 index d38fa3fc2..000000000 --- a/src/robusta/core/sinks/slack/templates/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Slack message templates package diff --git a/src/robusta/core/sinks/slack/templates/legacy.j2 b/src/robusta/core/sinks/slack/templates/legacy.j2 deleted file mode 100644 index f1b3831a0..000000000 --- a/src/robusta/core/sinks/slack/templates/legacy.j2 +++ /dev/null @@ -1,31 +0,0 @@ -{# Legacy template that mimics the original Slack message format from before templates #} -{# This template produces messages that look identical to pre-template Robusta #} - -{# First create the status/header block - different format based on source #} -{ - "type": "section", - "text": { - "type": "mrkdwn", - "text": "{% if finding.source == 'PROMETHEUS' %}{% if status_text == 'Firing' %}{{ status_emoji }} `Prometheus Alert Firing` {{ status_emoji }}{% else %}{{ status_emoji }} *Prometheus resolved*{% endif %}{% elif finding.source == 'KUBERNETES_API_SERVER' %}šŸ‘€ *K8s event detected*{% else %}šŸ‘€ *Notification*{% endif %} {{ severity_emoji }} *{{ severity }}*\n{% if platform_enabled and include_investigate_link %}<{{ investigate_uri }}|*{{ title }}*>{% else %}*{{ title }}*{% endif %}" - } -} - -{# Source block - old format used "Source" instead of "Cluster" #} -{ - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*Source:* `{{ cluster_name }}`" - } -} - -{# Description block if available - formatted based on source #} -{% if finding.description %} -{ - "type": "section", - "text": { - "type": "mrkdwn", - "text": "{% if finding.source == 'PROMETHEUS' %}šŸ”” *Alert:* {{ finding.description }}{% elif finding.source == 'KUBERNETES_API_SERVER' %}šŸ‘€ *K8s event detected:* {{ finding.description }}{% else %}šŸ‘€ *Notification:* {{ finding.description }}{% endif %}" - } -} -{% endif %} \ No newline at end of file diff --git a/src/robusta/core/sinks/slack/templates/template_loader.py b/src/robusta/core/sinks/slack/templates/template_loader.py index ab19e29af..f44af0256 100644 --- a/src/robusta/core/sinks/slack/templates/template_loader.py +++ b/src/robusta/core/sinks/slack/templates/template_loader.py @@ -78,31 +78,16 @@ def render_to_blocks(self, template: Template, context: Dict[str, Any]) -> List[ return [] def render_custom_template_to_blocks(self, custom_template: str, context: Dict[str, Any]) -> List[Dict[str, Any]]: - """ - Render a custom Jinja template string to Slack blocks. - Args: - custom_template: Jinja template string - context: Dictionary of variables to pass to the template - Returns: - List of Slack block objects (dictionaries) - """ try: template = Template(custom_template) return self.render_to_blocks(template, context) except Exception as e: logging.error(f"Error rendering custom template: {e}") - return [] + return self.render_default_template_to_blocks(context) - def render_file_template_to_blocks(self, template_name: str, context: Dict[str, Any]) -> List[Dict[str, Any]]: - """ - Render a file-based Jinja template to Slack blocks. - Args: - template_name: The name of the file-based template (e.g., "header.j2") - context: Dictionary of variables to pass to the template - Returns: - List of Slack block objects (dictionaries) - """ - template = self.get_template(template_name) + def render_default_template_to_blocks(self, context: Dict[str, Any]) -> List[Dict[str, Any]]: + DEFAULT_TEMPLATE_NAME="header.j2" + template = self.get_template(DEFAULT_TEMPLATE_NAME) return self.render_to_blocks(template, context) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 65316d0e6..da5e018ef 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -509,7 +509,7 @@ def __create_finding_header_preview( } # Determine the template name to use - template_name = sink_params.get_effective_template_name() if sink_params else "header.j2" + template_name = sink_params.get_template_name() if sink_params else "header.j2" # Get the custom template for this template name, if any custom_template = sink_params.get_custom_template() if sink_params else None @@ -518,7 +518,7 @@ def __create_finding_header_preview( if custom_template: return template_loader.render_custom_template_to_blocks(custom_template, template_context) else: - return template_loader.render_file_template_to_blocks(template_name, template_context) + return template_loader.render_default_template_to_blocks(template_context) def __create_finding_header( self, finding: Finding, status: FindingStatus, platform_enabled: bool, include_investigate_link: bool @@ -676,12 +676,15 @@ def send_finding_to_slack( thread_ts: str = None, ) -> str: if self.is_preview: - return self.__send_finding_to_slack_preview( - finding=finding, - sink_params=sink_params, - platform_enabled=platform_enabled, - thread_ts=thread_ts - ) + try: + return self.__send_finding_to_slack_preview( + finding=finding, + sink_params=sink_params, + platform_enabled=platform_enabled, + thread_ts=thread_ts + ) + except Exception: + logging.exception("Failed to render slack preview template, defaulting to legacy slack output") return self.__send_finding_to_slack( finding=finding, sink_params=sink_params, @@ -793,14 +796,11 @@ def __send_finding_to_slack_preview( FindingStatus.RESOLVED if finding.title.startswith("[RESOLVED]") else FindingStatus.FIRING ) - # Get JIRA-style header blocks if finding.title: header_blocks = self.__create_finding_header_preview(finding, status, platform_enabled, sink_params.investigate_link, sink_params) - # Description handling - moved above the buttons if finding.description: - # Always show description immediately after title description_text = finding.description blocks.append(MarkdownBlock(description_text)) diff --git a/tests/manual_tests/test_slack_integration_manual.py b/tests/manual_tests/test_slack_integration_manual.py index 893af5162..96f9cda54 100644 --- a/tests/manual_tests/test_slack_integration_manual.py +++ b/tests/manual_tests/test_slack_integration_manual.py @@ -25,7 +25,7 @@ ) from robusta.core.model.base_params import ResourceInfo from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams -from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewParams, SlackTemplateStyle +from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewParams from robusta.core.playbooks.internal.ai_integration import ask_holmes # Configure logging - set to DEBUG to see full block details From 3c1f7091dd5435f520dab5a087a34d149610319d Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Wed, 21 May 2025 15:50:47 +0300 Subject: [PATCH 36/42] refactoring sender --- src/robusta/integrations/slack/sender.py | 128 +++++------------------ 1 file changed, 29 insertions(+), 99 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index da5e018ef..d5115f3dd 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -777,7 +777,6 @@ def __send_finding_to_slack_preview( ) -> str: blocks: List[BaseBlock] = [] attachment_blocks: List[BaseBlock] = [] - header_blocks: List[SlackBlock] = [] # JIRA-style header blocks slack_channel = ChannelTransformer.template( sink_params.channel_override, @@ -797,8 +796,8 @@ def __send_finding_to_slack_preview( ) if finding.title: - header_blocks = self.__create_finding_header_preview(finding, status, platform_enabled, - sink_params.investigate_link, sink_params) + blocks.extend(self.__create_finding_header_preview(finding, status, platform_enabled, + sink_params.investigate_link, sink_params)) if finding.description: description_text = finding.description @@ -812,113 +811,44 @@ def __send_finding_to_slack_preview( if HOLMES_ENABLED and HOLMES_ASK_SLACK_BUTTON_ENABLED: blocks.append(self.__create_holmes_callback(finding)) - unfurl = True - all_file_blocks = [] # Collect all file blocks to be handled specially + blocks.append(MarkdownBlock(text=f"*Source:* `{self.cluster_name}`")) + if finding.description: + if finding.source == FindingSource.PROMETHEUS: + blocks.append(MarkdownBlock(f"{Emojis.Alert.value} *Alert:* {finding.description}")) + elif finding.source == FindingSource.KUBERNETES_API_SERVER: + blocks.append( + MarkdownBlock(f"{Emojis.K8Notification.value} *K8s event detected:* {finding.description}") + ) + else: + blocks.append(MarkdownBlock(f"{Emojis.K8Notification.value} *Notification:* {finding.description}")) + unfurl = True for enrichment in finding.enrichments: if enrichment.annotations.get(EnrichmentAnnotation.SCAN, False): enrichment.blocks = [Transformer.scanReportBlock_to_fileblock(b) for b in enrichment.blocks] # if one of the enrichment specified unfurl=False, this slack message will contain unfurl=False unfurl = bool(unfurl and enrichment.annotations.get(SlackAnnotations.UNFURL, True)) - - # Separate file blocks from normal blocks - file_blocks_in_enrichment = [b for b in enrichment.blocks if isinstance(b, FileBlock)] - non_file_blocks = [b for b in enrichment.blocks if not isinstance(b, FileBlock)] - - # Collect file blocks - all_file_blocks.extend(file_blocks_in_enrichment) - - # Put non-file blocks in the attachment - attachment_blocks.extend(non_file_blocks) - - # Add file blocks to the main blocks for proper handling - blocks.extend(all_file_blocks) - - # No divider in the main blocks - - # We need to create a minimal version of __send_blocks_to_slack - # that can handle both our header blocks and regular blocks - file_blocks = add_pngs_for_all_svgs([b for b in blocks if isinstance(b, FileBlock)]) - if not sink_params.send_svg: - file_blocks = [b for b in file_blocks if not b.filename.endswith(".svg")] - - other_blocks = [b for b in blocks if not isinstance(b, FileBlock)] - - # wide tables aren't displayed properly on slack. looks better in a text file - file_blocks.extend(Transformer.tableblock_to_fileblocks(other_blocks, SLACK_TABLE_COLUMNS_LIMIT)) - file_blocks.extend(Transformer.tableblock_to_fileblocks(attachment_blocks, SLACK_TABLE_COLUMNS_LIMIT)) - - message, error_msg = self.prepare_slack_text( - finding.title, max_log_file_limit_kb=sink_params.max_log_file_limit_kb, files=file_blocks - ) - - # Convert BaseBlocks to Slack blocks - output_blocks = [] - for block in other_blocks: - output_blocks.extend(self.__to_slack(block, sink_params.name)) - - attachment_slack_blocks = [] - for block in attachment_blocks: - attachment_slack_blocks.extend(self.__to_slack(block, sink_params.name)) - - # Combine with header blocks - all_blocks = header_blocks + output_blocks - - try: - if thread_ts: - kwargs = {"thread_ts": thread_ts} + if enrichment.annotations.get(SlackAnnotations.ATTACHMENT): + attachment_blocks.extend(enrichment.blocks) else: - kwargs = {} - - # Create a single attachment with all blocks and a divider at the end - attachments = [] - - # Add divider to the end of attachment blocks if there are any - all_attachment_blocks = attachment_slack_blocks.copy() if attachment_slack_blocks else [] - - # Always add a divider at the end - all_attachment_blocks.append({"type": "divider"}) - - # Create a single attachment with the status color - attachments = [{ - "color": status.to_color_hex(), - "blocks": all_attachment_blocks - }] - - # Detailed logging of blocks before sending to Slack - logging.debug( - f"SENDING TO SLACK - FULL BLOCKS DETAIL:\n" - f"CHANNEL: {slack_channel}\n" - f"TITLE: {finding.title}\n" - f"HEADER BLOCKS: {json.dumps(header_blocks, indent=2)}\n" - f"MAIN BLOCKS: {json.dumps(output_blocks, indent=2)}\n" - f"ALL BLOCKS: {json.dumps(all_blocks, indent=2)}\n" - f"ATTACHMENT BLOCKS: {json.dumps(all_attachment_blocks, indent=2)}\n" - f"ATTACHMENTS: {json.dumps(attachments, indent=2)}\n" - ) + blocks.extend(enrichment.blocks) - # Send the message with our JIRA-style headers and attachments - resp = self.slack_client.chat_postMessage( - channel=slack_channel, - text=message, - blocks=all_blocks, - display_as_bot=True, - attachments=attachments, - unfurl_links=unfurl, - unfurl_media=unfurl, - **kwargs, - ) + blocks.append(DividerBlock()) - # Store channel id for future use - self.channel_name_to_id[slack_channel] = resp["channel"] - return resp["ts"] + if len(attachment_blocks): + attachment_blocks.append(DividerBlock()) - except Exception as e: - logging.error( - f"error sending message to slack\ne={e}\ntext={message}\nchannel={slack_channel}" - ) - return "" + return self.__send_blocks_to_slack( + blocks, + attachment_blocks, + finding.title, + sink_params, + unfurl, + status, + slack_channel, + thread_ts=thread_ts, + ) def send_or_update_summary_message( self, From 57f094c92ec6cac11affd487c31495444633ee85 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Wed, 21 May 2025 15:55:18 +0300 Subject: [PATCH 37/42] remove unneeded code --- tests/test_slack_preview.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_slack_preview.py b/tests/test_slack_preview.py index 954db2a03..9a2ce3da1 100755 --- a/tests/test_slack_preview.py +++ b/tests/test_slack_preview.py @@ -1,7 +1,3 @@ -# This file is being moved to tests/test_slack_templates.py and refactored to match the style of test_slack.py. -# The script/main logic and unused code are removed. - -import pytest from robusta.core.reporting.base import Finding, FindingSeverity, FindingSource, FindingSubject, FindingSubjectType from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewParams from robusta.core.reporting.blocks import MarkdownBlock From 7b9daf23f49096d7117fb4185926761fc57a46e8 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Sun, 25 May 2025 11:03:57 +0300 Subject: [PATCH 38/42] added additional features added hide_buttons, added hide_enrichments does not show description on custom template --- docs/configuration/sinks/slack.rst | 96 +++++++++++-------- .../preview/slack_sink_preview_params.py | 12 +-- src/robusta/integrations/slack/sender.py | 54 ++++++----- .../test_slack_integration_manual.py | 12 ++- 4 files changed, 102 insertions(+), 72 deletions(-) diff --git a/docs/configuration/sinks/slack.rst b/docs/configuration/sinks/slack.rst index 13fcb10a6..8f7c5d5e5 100644 --- a/docs/configuration/sinks/slack.rst +++ b/docs/configuration/sinks/slack.rst @@ -200,43 +200,58 @@ To use custom templates change your `slack_sink` to `slack_sink_preview`, and ad slack_custom_templates: custom_template.j2: |- { - "type": "header", - "text": { - "type": "plain_text", - "text": "Custom Alert Format:\n {{ status_emoji }} [{{ status_text }}] {{ title }}", - "emoji": true - } - } - - { - "type": "divider" - } - - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Type:* {{ alert_type }}" - }, - { - "type": "mrkdwn", - "text": "*Severity:* {{ severity_emoji }} {{ severity }}" - }, - { - "type": "mrkdwn", - "text": "*Cluster:* {{ cluster_name }}" - } - {% if resource_text %} - , - { - "type": "mrkdwn", - "text": "*Resource:*\n{{ resource_text }}" - } - {% endif %} - ] - } - + "type": "header", + "text": { + "type": "plain_text", + "text": "Custom Alert Format:\n {{ status_emoji }} [{{ status_text }}] {{ title }}", + "emoji": true + } + } + + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{{ status_emoji }} *[{{ status_text }}] {{ title }}*{% if mention %} {{ mention }}{% endif %}" + } + } + + { + "type": "divider" + } + + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Type:* {{ alert_type }}" + }, + { + "type": "mrkdwn", + "text": "*Severity:* {{ severity_emoji }} {{ severity }}" + }, + { + "type": "mrkdwn", + "text": "*Cluster:* {{ cluster_name }}" + } + {% if resource_text %} + , + { + "type": "mrkdwn", + "text": "*Resource:*\\n{{ resource_text }}" + } + {% endif %} + ] + } + + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{% if labels %}*Labels:*\\n\\n{% for key, value in labels.items() %}• *{{ key }}*: {{ value }}\\n\\n{% endfor %}{% else %}*Labels:* _None_{% endif %}" + } + } Templates use Slack's Block Kit format and must generate valid JSON. Each template block is separated by double newlines (``\n\n``). @@ -247,6 +262,8 @@ Available template variables: +=============================+=============================================================+ | ``title`` | The alert title | +-----------------------------+-------------------------------------------------------------+ +| ``description`` | The alert description | ++-----------------------------+-------------------------------------------------------------+ | ``status_text`` | "Firing" or "Resolved" | +-----------------------------+-------------------------------------------------------------+ | ``status_emoji`` | "āš ļø" (for firing) or "āœ…" (for resolved) | @@ -273,6 +290,7 @@ Available template variables: +-----------------------------+-------------------------------------------------------------+ | ``mention`` | Any @mentions extracted from the title | +-----------------------------+-------------------------------------------------------------+ -| ``finding`` | The complete finding object with all alert data | +| ``labels`` | Kubernetes labels on the subject resource (dict) | ++-----------------------------+-------------------------------------------------------------+ +| ``annotations`` | Kubernetes annotations on the subject resource (dict) | +-----------------------------+-------------------------------------------------------------+ - diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py index f3cc647ce..71b17f3ba 100644 --- a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py @@ -9,6 +9,9 @@ class SlackSinkPreviewParams(SlackSinkParams): #TODO: improve the SlackSinkPreviewParams so the slack_custom_templates can be defined once globally and # only a template name needs to be passed to each channel in the config slack_custom_templates: Optional[Dict[str, str]] = None # Template name -> custom template content + hide_buttons: bool = False + hide_enrichments: bool = False + @validator('slack_custom_templates') def check_one_item(cls, v): @@ -16,15 +19,6 @@ def check_one_item(cls, v): raise ValueError("slack_custom_templates must contain exactly one key-value pair") return v - def get_template_name(self) -> str: - """ - Returns the template name to use for this sink. If slack_custom_templates is set, use the first one. - Otherwise, use 'header.j2'. - """ - if self.slack_custom_templates: - return next(iter(self.slack_custom_templates)) - return "header.j2" - def get_custom_template(self) -> Optional[str]: """Get the custom template if defined""" if not self.slack_custom_templates: diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index d5115f3dd..939f63be5 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -5,7 +5,7 @@ import re from datetime import datetime, timedelta from itertools import chain -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Optional, Set, Union import json import certifi import humanize @@ -487,10 +487,11 @@ def __create_finding_header_preview( resource_id = f"{subject_kind}/{subject_namespace}/{subject_name}" else: resource_id = f"{subject_kind}/{subject_name}" - + description = finding.description or "" # Prepare template context template_context = { "title": self.__slack_preview_sanitize_string(title), + "description": self.__slack_preview_sanitize_string(description), "status_text": status_text, "status_emoji": status_emoji, "severity": sev.name.capitalize(), @@ -506,15 +507,11 @@ def __create_finding_header_preview( "subject_name": subject_name, "resource_emoji": resource_emoji, "mention": mention, + "labels": finding.subject.labels if finding.subject else {}, + "annotations": finding.subject.annotations if finding.subject else {}, } - # Determine the template name to use - template_name = sink_params.get_template_name() if sink_params else "header.j2" - - # Get the custom template for this template name, if any custom_template = sink_params.get_custom_template() if sink_params else None - - # Use the new template loader methods for custom or file-based templates if custom_template: return template_loader.render_custom_template_to_blocks(custom_template, template_context) else: @@ -671,7 +668,7 @@ def send_holmes_analysis( def send_finding_to_slack( self, finding: Finding, - sink_params: SlackSinkParams, + sink_params: Union[SlackSinkParams, SlackSinkPreviewParams], platform_enabled: bool, thread_ts: str = None, ) -> str: @@ -771,11 +768,11 @@ def __send_finding_to_slack( def __send_finding_to_slack_preview( self, finding: Finding, - sink_params: SlackSinkParams, + sink_params: SlackSinkPreviewParams, platform_enabled: bool, thread_ts: str = None, ) -> str: - blocks: List[BaseBlock] = [] + blocks: List[SlackBlock] = [] attachment_blocks: List[BaseBlock] = [] slack_channel = ChannelTransformer.template( @@ -799,20 +796,19 @@ def __send_finding_to_slack_preview( blocks.extend(self.__create_finding_header_preview(finding, status, platform_enabled, sink_params.investigate_link, sink_params)) - if finding.description: - description_text = finding.description - blocks.append(MarkdownBlock(description_text)) + if not sink_params.hide_buttons: + links_block: LinksBlock = self.__create_links( + finding, platform_enabled, sink_params.investigate_link, sink_params.prefer_redirect_to_platform + ) + blocks.append(links_block) - links_block: LinksBlock = self.__create_links( - finding, platform_enabled, sink_params.investigate_link, sink_params.prefer_redirect_to_platform - ) - blocks.append(links_block) + if HOLMES_ENABLED and HOLMES_ASK_SLACK_BUTTON_ENABLED: + blocks.append(self.__create_holmes_callback(finding)) - if HOLMES_ENABLED and HOLMES_ASK_SLACK_BUTTON_ENABLED: - blocks.append(self.__create_holmes_callback(finding)) + if not sink_params.get_custom_template(): + blocks.append(MarkdownBlock(text=f"*Source:* `{self.cluster_name}`")) - blocks.append(MarkdownBlock(text=f"*Source:* `{self.cluster_name}`")) - if finding.description: + if not sink_params.get_custom_template() and finding.description: if finding.source == FindingSource.PROMETHEUS: blocks.append(MarkdownBlock(f"{Emojis.Alert.value} *Alert:* {finding.description}")) elif finding.source == FindingSource.KUBERNETES_API_SERVER: @@ -821,8 +817,20 @@ def __send_finding_to_slack_preview( ) else: blocks.append(MarkdownBlock(f"{Emojis.K8Notification.value} *Notification:* {finding.description}")) - unfurl = True + + if sink_params.hide_enrichments: + return self.__send_blocks_to_slack( + blocks, + attachment_blocks, + finding.title, + sink_params, + unfurl, + status, + slack_channel, + thread_ts=thread_ts, + ) + for enrichment in finding.enrichments: if enrichment.annotations.get(EnrichmentAnnotation.SCAN, False): enrichment.blocks = [Transformer.scanReportBlock_to_fileblock(b) for b in enrichment.blocks] diff --git a/tests/manual_tests/test_slack_integration_manual.py b/tests/manual_tests/test_slack_integration_manual.py index 96f9cda54..473fbbbc6 100644 --- a/tests/manual_tests/test_slack_integration_manual.py +++ b/tests/manual_tests/test_slack_integration_manual.py @@ -591,6 +591,14 @@ def main(): } {% endif %} ] + } + + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{% if labels %}*Labels:*\\n\\n{% for key, value in labels.items() %}• *{{ key }}*: {{ value }}\\n\\n{% endfor %}{% else %}*Labels:* _None_{% endif %}" + } }""" custom_params = SlackSinkPreviewParams( name="custom-sink", @@ -599,7 +607,9 @@ def main(): investigate_link=True, prefer_redirect_to_platform=False, max_log_file_limit_kb=1000, - slack_custom_templates={"custom.j2": custom_template} + slack_custom_templates={"custom.j2": custom_template}, + hide_enrichments=False, + hide_buttons=False, ) preview_sender.send_finding_to_slack(finding, custom_params, platform_enabled=True) From 6546ba537b9cba9f43dbf9c8cd9db809bed533b6 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Sun, 25 May 2025 11:13:12 +0300 Subject: [PATCH 39/42] changed manual tests --- tests/manual_tests/test_slack_integration_manual.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/manual_tests/test_slack_integration_manual.py b/tests/manual_tests/test_slack_integration_manual.py index 473fbbbc6..d9751338c 100644 --- a/tests/manual_tests/test_slack_integration_manual.py +++ b/tests/manual_tests/test_slack_integration_manual.py @@ -597,7 +597,7 @@ def main(): "type": "section", "text": { "type": "mrkdwn", - "text": "{% if labels %}*Labels:*\\n\\n{% for key, value in labels.items() %}• *{{ key }}*: {{ value }}\\n\\n{% endfor %}{% else %}*Labels:* _None_{% endif %}" + "text": "{% if labels %}*Custom Labels Format:*\\n\\n{% for key, value in labels.items() %}• *{{ key }}*: {{ value }}\\n\\n{% endfor %}{% else %}*Labels:* _None_{% endif %}" } }""" custom_params = SlackSinkPreviewParams( @@ -608,8 +608,8 @@ def main(): prefer_redirect_to_platform=False, max_log_file_limit_kb=1000, slack_custom_templates={"custom.j2": custom_template}, - hide_enrichments=False, - hide_buttons=False, + hide_enrichments=True, + hide_buttons=True, ) preview_sender.send_finding_to_slack(finding, custom_params, platform_enabled=True) From 640ae3757eccec8d133f1123383004b58b386197 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Sun, 25 May 2025 13:04:41 +0300 Subject: [PATCH 40/42] add aggregation key --- src/robusta/integrations/slack/sender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 939f63be5..29e278454 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -455,7 +455,6 @@ def __create_finding_header_preview( else: alert_type = "Notification" - # Prepare resource text and emoji if available resource_emoji = ":package:" subject_kind = "" @@ -507,6 +506,7 @@ def __create_finding_header_preview( "subject_name": subject_name, "resource_emoji": resource_emoji, "mention": mention, + "aggregation_key": finding.aggregation_key, "labels": finding.subject.labels if finding.subject else {}, "annotations": finding.subject.annotations if finding.subject else {}, } From 1a7ee23c77fa0b54f9316073d018583d553e9210 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Mon, 26 May 2025 10:20:34 +0300 Subject: [PATCH 41/42] pr changes --- src/robusta/integrations/slack/sender.py | 50 +++++++++--------------- 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 29e278454..b25e15cb6 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -6,7 +6,6 @@ from datetime import datetime, timedelta from itertools import chain from typing import Any, Dict, List, Optional, Set, Union -import json import certifi import humanize from dateutil import tz @@ -311,6 +310,7 @@ def __send_blocks_to_slack( status: FindingStatus, channel: str, thread_ts: str = None, + output_blocks: List[SlackBlock] = [], ) -> str: file_blocks = add_pngs_for_all_svgs([b for b in report_blocks if isinstance(b, FileBlock)]) if not sink_params.send_svg: @@ -328,7 +328,6 @@ def __send_blocks_to_slack( if error_msg: other_blocks.append(MarkdownBlock(error_msg)) - output_blocks = [] for block in other_blocks: output_blocks.extend(self.__to_slack(block, sink_params.name)) attachment_blocks = [] @@ -435,7 +434,7 @@ def __create_finding_header_preview( self, finding: Finding, status: FindingStatus, platform_enabled: bool, include_investigate_link: bool, sink_params: SlackSinkPreviewParams = None ) -> List[SlackBlock]: - title = finding.title.removeprefix("[RESOLVED] ") + title = finding.title.removeprefix("[RESOLVED] ") if finding.title else "" title, mention = self.extract_mentions(title) @@ -772,7 +771,7 @@ def __send_finding_to_slack_preview( platform_enabled: bool, thread_ts: str = None, ) -> str: - blocks: List[SlackBlock] = [] + blocks: List[BaseBlock] = [] attachment_blocks: List[BaseBlock] = [] slack_channel = ChannelTransformer.template( @@ -792,9 +791,8 @@ def __send_finding_to_slack_preview( FindingStatus.RESOLVED if finding.title.startswith("[RESOLVED]") else FindingStatus.FIRING ) - if finding.title: - blocks.extend(self.__create_finding_header_preview(finding, status, platform_enabled, - sink_params.investigate_link, sink_params)) + slack_blocks: List[SlackBlock] = self.__create_finding_header_preview(finding, status, platform_enabled, + sink_params.investigate_link, sink_params) if not sink_params.hide_buttons: links_block: LinksBlock = self.__create_links( @@ -819,33 +817,22 @@ def __send_finding_to_slack_preview( blocks.append(MarkdownBlock(f"{Emojis.K8Notification.value} *Notification:* {finding.description}")) unfurl = True - if sink_params.hide_enrichments: - return self.__send_blocks_to_slack( - blocks, - attachment_blocks, - finding.title, - sink_params, - unfurl, - status, - slack_channel, - thread_ts=thread_ts, - ) - - for enrichment in finding.enrichments: - if enrichment.annotations.get(EnrichmentAnnotation.SCAN, False): - enrichment.blocks = [Transformer.scanReportBlock_to_fileblock(b) for b in enrichment.blocks] + if not sink_params.hide_enrichments: + for enrichment in finding.enrichments: + if enrichment.annotations.get(EnrichmentAnnotation.SCAN, False): + enrichment.blocks = [Transformer.scanReportBlock_to_fileblock(b) for b in enrichment.blocks] - # if one of the enrichment specified unfurl=False, this slack message will contain unfurl=False - unfurl = bool(unfurl and enrichment.annotations.get(SlackAnnotations.UNFURL, True)) - if enrichment.annotations.get(SlackAnnotations.ATTACHMENT): - attachment_blocks.extend(enrichment.blocks) - else: - blocks.extend(enrichment.blocks) + # if one of the enrichment specified unfurl=False, this slack message will contain unfurl=False + unfurl = bool(unfurl and enrichment.annotations.get(SlackAnnotations.UNFURL, True)) + if enrichment.annotations.get(SlackAnnotations.ATTACHMENT): + attachment_blocks.extend(enrichment.blocks) + else: + blocks.extend(enrichment.blocks) - blocks.append(DividerBlock()) + blocks.append(DividerBlock()) - if len(attachment_blocks): - attachment_blocks.append(DividerBlock()) + if len(attachment_blocks): + attachment_blocks.append(DividerBlock()) return self.__send_blocks_to_slack( blocks, @@ -856,6 +843,7 @@ def __send_finding_to_slack_preview( status, slack_channel, thread_ts=thread_ts, + output_blocks=slack_blocks, ) def send_or_update_summary_message( From e5094c4e23b4e98448b21b309fdf632aaf743263 Mon Sep 17 00:00:00 2001 From: "avi@robusta.dev" Date: Mon, 26 May 2025 13:12:01 +0300 Subject: [PATCH 42/42] added fingerprint to template --- docs/configuration/sinks/slack.rst | 2 ++ src/robusta/integrations/slack/sender.py | 1 + 2 files changed, 3 insertions(+) diff --git a/docs/configuration/sinks/slack.rst b/docs/configuration/sinks/slack.rst index 8f7c5d5e5..3cd7c7641 100644 --- a/docs/configuration/sinks/slack.rst +++ b/docs/configuration/sinks/slack.rst @@ -294,3 +294,5 @@ Available template variables: +-----------------------------+-------------------------------------------------------------+ | ``annotations`` | Kubernetes annotations on the subject resource (dict) | +-----------------------------+-------------------------------------------------------------+ +| ``fingerprint`` | The unique identifier for the alert | ++-----------------------------+-------------------------------------------------------------+ diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index b25e15cb6..a9854b4a9 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -508,6 +508,7 @@ def __create_finding_header_preview( "aggregation_key": finding.aggregation_key, "labels": finding.subject.labels if finding.subject else {}, "annotations": finding.subject.annotations if finding.subject else {}, + "fingerprint": finding.fingerprint, } custom_template = sink_params.get_custom_template() if sink_params else None