diff --git a/elementary/config/config.py b/elementary/config/config.py index b20fa2309..5e759c77c 100644 --- a/elementary/config/config.py +++ b/elementary/config/config.py @@ -72,6 +72,7 @@ def __init__( report_url: Optional[str] = None, teams_webhook: Optional[str] = None, maximum_columns_in_alert_samples: Optional[int] = None, + slack_full_width: Optional[bool] = None, env: str = DEFAULT_ENV, run_dbt_deps_if_needed: Optional[bool] = None, project_name: Optional[str] = None, @@ -144,6 +145,11 @@ def __init__( slack_config.get("group_alerts_threshold"), self.DEFAULT_GROUP_ALERTS_THRESHOLD, ) + self.slack_full_width = self._first_not_none( + slack_full_width, + slack_config.get("full_width"), + False, + ) teams_config = config.get(self._TEAMS, {}) self.teams_webhook = self._first_not_none( diff --git a/elementary/messages/formats/block_kit.py b/elementary/messages/formats/block_kit.py index a0cc0cd79..163e4ed69 100644 --- a/elementary/messages/formats/block_kit.py +++ b/elementary/messages/formats/block_kit.py @@ -273,7 +273,9 @@ def _add_table_block(self, block: TableBlock) -> None: new_headers = [ self._format_table_cell(cell, column_count) for cell in block.headers ] - table_text = tabulate(new_rows, headers=new_headers, tablefmt="simple") + table_text = tabulate( + new_rows, headers=new_headers, tablefmt="simple", disable_numparse=True + ) self._add_block(self._format_markdown_section(f"```{table_text}```")) def _add_actions_block(self, block: ActionsBlock) -> None: diff --git a/elementary/messages/formats/markdown.py b/elementary/messages/formats/markdown.py index a8a454211..d0427e337 100644 --- a/elementary/messages/formats/markdown.py +++ b/elementary/messages/formats/markdown.py @@ -95,7 +95,12 @@ def format_fact_list_block(self, block: FactListBlock) -> str: def format_table_block(self, block: TableBlock) -> str: if self._table_style == TableStyle.TABULATE: - table = tabulate(block.rows, headers=block.headers, tablefmt="simple") + table = tabulate( + block.rows, + headers=block.headers, + tablefmt="simple", + disable_numparse=True, + ) return f"```\n{table}\n```" elif self._table_style == TableStyle.JSON: dicts = [ diff --git a/elementary/messages/formats/text.py b/elementary/messages/formats/text.py index 18aaaddb5..ed015bfd8 100644 --- a/elementary/messages/formats/text.py +++ b/elementary/messages/formats/text.py @@ -90,7 +90,12 @@ def format_fact_list_block(self, block: FactListBlock) -> str: def format_table_block(self, block: TableBlock) -> str: if self._table_style == TableStyle.TABULATE: - return tabulate(block.rows, headers=block.headers, tablefmt="simple") + return tabulate( + block.rows, + headers=block.headers, + tablefmt="simple", + disable_numparse=True, + ) elif self._table_style == TableStyle.JSON: dicts = [ {header: cell for header, cell in zip(block.headers, row)} diff --git a/elementary/monitor/cli.py b/elementary/monitor/cli.py index 9b47133ff..2b066a9d9 100644 --- a/elementary/monitor/cli.py +++ b/elementary/monitor/cli.py @@ -298,6 +298,12 @@ def get_cli_properties() -> dict: default=4, help="Maximum number of columns to display as a table in alert samples. Above this, the output is shown as raw JSON.", ) +@click.option( + "--slack-full-width", + is_flag=True, + default=False, + help="When set, Slack alerts use rich text to achieve full message width instead of the default narrower layout with attachments.", +) @click.pass_context def monitor( ctx, @@ -331,6 +337,7 @@ def monitor( teams_webhook, maximum_columns_in_alert_samples, quiet_logs, + slack_full_width, ): """ Get alerts on failures in dbt jobs. @@ -365,6 +372,7 @@ def monitor( teams_webhook=teams_webhook, maximum_columns_in_alert_samples=maximum_columns_in_alert_samples, quiet_logs=quiet_logs, + slack_full_width=slack_full_width, ) anonymous_tracking = AnonymousCommandLineTracking(config) anonymous_tracking.set_env("use_select", bool(select)) diff --git a/elementary/monitor/data_monitoring/alerts/integrations/integrations.py b/elementary/monitor/data_monitoring/alerts/integrations/integrations.py index 7a8ed59db..9139ddedb 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/integrations.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/integrations.py @@ -43,7 +43,7 @@ def get_integration( tracking: Optional[Tracking] = None, ) -> Union[BaseMessagingIntegration, BaseIntegration]: if config.has_slack: - if config.is_slack_workflow: + if config.is_slack_workflow or config.slack_full_width: return SlackIntegration( config=config, tracking=tracking, diff --git a/elementary/monitor/data_monitoring/alerts/integrations/slack/message_builder.py b/elementary/monitor/data_monitoring/alerts/integrations/slack/message_builder.py index 1077bacab..b167daee5 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/slack/message_builder.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/slack/message_builder.py @@ -26,16 +26,36 @@ class SlackAlertMessageSchema(BaseModel): class SlackAlertMessageBuilder(SlackMessageBuilder): - def __init__(self) -> None: + def __init__(self, full_width: bool = False) -> None: super().__init__() + self.full_width = full_width def get_slack_message( self, alert_schema: SlackAlertMessageSchema, ) -> SlackMessageSchema: + if self.full_width: + # A rich_text block at the start forces Slack to use full message width + # for following blocks instead of the narrower attachment-style layout. + # The elements array must be non-empty per Slack Block Kit API. + self._add_always_displayed_blocks( + [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": " "}], + } + ], + } + ] + ) self.add_title_to_slack_alert(alert_schema.title) self.add_preview_to_slack_alert(alert_schema.preview) self.add_details_to_slack_alert(alert_schema.details) + if self.full_width: + self.slack_message["attachments"] = [] return super().get_slack_message() def add_title_to_slack_alert(self, title_blocks: Optional[SlackBlocksType] = None): @@ -46,15 +66,23 @@ def add_title_to_slack_alert(self, title_blocks: Optional[SlackBlocksType] = Non def add_preview_to_slack_alert( self, preview_blocks: Optional[SlackBlocksType] = None ): - if preview_blocks: - validated_preview_blocks = self._validate_preview_blocks(preview_blocks) + if not preview_blocks: + return + validated_preview_blocks = self._validate_preview_blocks(preview_blocks) + if self.full_width: + self._add_always_displayed_blocks(validated_preview_blocks) + else: self._add_blocks_as_attachments(validated_preview_blocks) def add_details_to_slack_alert( self, detail_blocks: Optional[SlackBlocksType] = None, ): - if detail_blocks: + if not detail_blocks: + return + if self.full_width: + self._add_always_displayed_blocks(detail_blocks) + else: self._add_blocks_as_attachments(detail_blocks) @classmethod diff --git a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py index a0e5ce30d..52e9591f0 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py @@ -9,6 +9,8 @@ from elementary.clients.slack.schema import SlackBlocksType, SlackMessageSchema from elementary.clients.slack.slack_message_builder import MessageColor from elementary.config.config import Config +from elementary.messages.blocks import Icon +from elementary.messages.formats.unicode import ICON_TO_UNICODE from elementary.monitor.alerts.alerts_groups import AlertsGroup, GroupedByTableAlerts from elementary.monitor.alerts.alerts_groups.base_alerts_group import BaseAlertsGroup from elementary.monitor.alerts.model_alert import ModelAlertModel @@ -26,6 +28,7 @@ ) from elementary.tracking.tracking_interface import Tracking from elementary.utils.json_utils import ( + list_of_dicts_to_markdown_table, list_of_lists_of_strings_to_comma_delimited_unique_strings, ) from elementary.utils.log import get_logger @@ -78,7 +81,9 @@ def __init__( self.config = config self.tracking = tracking self.override_config_defaults = override_config_defaults - self.message_builder = SlackAlertMessageBuilder() + self.message_builder = SlackAlertMessageBuilder( + full_width=config.slack_full_width + ) super().__init__() # Enforce typing @@ -116,7 +121,10 @@ def _get_dbt_test_template( title = [ self.message_builder.create_header_block( f"{self._get_display_name(alert.status)}: {alert.summary}" - ) + ), + self.message_builder.create_text_section_block( + "Powered by " + ), ] if alert.suppression_interval: title.extend( @@ -165,8 +173,11 @@ def _get_dbt_test_template( ) compacted_sections = [] - if COLUMN_FIELD in (alert.alert_fields or DEFAULT_ALERT_FIELDS): - compacted_sections.append(f"*Column*\n{alert.column_name or '_No column_'}") + if ( + COLUMN_FIELD in (alert.alert_fields or DEFAULT_ALERT_FIELDS) + and alert.column_name + ): + compacted_sections.append(f"*Column*\n{alert.column_name}") if TAGS_FIELD in (alert.alert_fields or DEFAULT_ALERT_FIELDS): tags = prettify_and_dedup_list(alert.tags or []) compacted_sections.append(f"*Tags*\n{tags or '_No tags_'}") @@ -186,21 +197,12 @@ def _get_dbt_test_template( ) if DESCRIPTION_FIELD in (alert.alert_fields or DEFAULT_ALERT_FIELDS): - if alert.test_description: - preview.extend( - [ - self.message_builder.create_text_section_block("*Description*"), - self.message_builder.create_context_block( - [alert.test_description] - ), - ] - ) - else: - preview.append( - self.message_builder.create_text_section_block( - "*Description*\n_No description_" - ) + description_text = alert.test_description or "_No description_" + preview.append( + self.message_builder.create_text_section_block( + f"*Description*\n{description_text}" ) + ) result = [] if ( @@ -209,7 +211,7 @@ def _get_dbt_test_template( ): result.extend( [ - self.message_builder.create_context_block(["*Result message*"]), + self.message_builder.create_text_section_block("*Result message*"), self.message_builder.create_text_section_block( f"```{alert.error_message.strip()}```" ), @@ -220,13 +222,17 @@ def _get_dbt_test_template( TEST_RESULTS_SAMPLE_FIELD in (alert.alert_fields or DEFAULT_ALERT_FIELDS) and alert.test_rows_sample ): + table_max_length = SectionBlock.text_max_length - 6 + test_rows_sample_table = list_of_dicts_to_markdown_table( + alert.test_rows_sample, max_length=table_max_length + ) result.extend( [ - self.message_builder.create_context_block( - ["*Test results sample*"] + self.message_builder.create_text_section_block( + f"{ICON_TO_UNICODE[Icon.MAGNIFYING_GLASS]} *Test results sample*" ), self.message_builder.create_text_section_block( - f"```{alert.test_rows_sample}```" + f"```{test_rows_sample_table}```" ), ] ) @@ -235,7 +241,9 @@ def _get_dbt_test_template( TEST_QUERY_FIELD in (alert.alert_fields or DEFAULT_ALERT_FIELDS) and alert.test_results_query ): - result.append(self.message_builder.create_context_block(["*Test query*"])) + result.append( + self.message_builder.create_text_section_block("*Test query*") + ) msg = f"```{alert.test_results_query}```" if len(msg) > SectionBlock.text_max_length: @@ -330,8 +338,11 @@ def _get_elementary_test_template( ) compacted_sections = [] - if COLUMN_FIELD in (alert.alert_fields or DEFAULT_ALERT_FIELDS): - compacted_sections.append(f"*Column*\n{alert.column_name or '_No column_'}") + if ( + COLUMN_FIELD in (alert.alert_fields or DEFAULT_ALERT_FIELDS) + and alert.column_name + ): + compacted_sections.append(f"*Column*\n{alert.column_name}") if TAGS_FIELD in (alert.alert_fields or DEFAULT_ALERT_FIELDS): tags = prettify_and_dedup_list(alert.tags or []) compacted_sections.append(f"*Tags*\n{tags or '_No tags_'}") @@ -1194,7 +1205,9 @@ def _create_single_alert_details_blocks( if result: details_blocks.extend( [ - self.message_builder.create_text_section_block(":mag: *Result*"), + self.message_builder.create_text_section_block( + f"{ICON_TO_UNICODE[Icon.INFO]} *Details*" + ), self.message_builder.create_divider_block(), *result, ] diff --git a/elementary/utils/json_utils.py b/elementary/utils/json_utils.py index d0e3ee3c1..9779b0edb 100644 --- a/elementary/utils/json_utils.py +++ b/elementary/utils/json_utils.py @@ -1,6 +1,8 @@ import json import math -from typing import Any, List, Optional, Union +from typing import Any, Dict, List, Optional, Union + +from tabulate import tabulate def try_load_json(value: Optional[Union[str, dict, list]]): @@ -94,3 +96,70 @@ def inf_and_nan_to_str(obj) -> Any: return [inf_and_nan_to_str(i) for i in obj] else: return obj + + +def _format_value(value: Any) -> str: + """Format a value for table display, avoiding scientific notation for floats.""" + if value is None: + return "" + if isinstance(value, float): + if math.isinf(value) or math.isnan(value): + return str(value) + # Format floats without scientific notation + if value == int(value) and abs(value) < 1e15: + return str(int(value)) + return f"{value:.10f}".rstrip("0").rstrip(".") + return str(value) + + +def list_of_dicts_to_markdown_table( + data: List[Dict[str, Any]], max_length: Optional[int] = None +) -> str: + """ + Convert a list of dictionaries with consistent keys to a markdown table string. + + Args: + data: List of dictionaries + max_length: Optional maximum character length for the output. If the full + table exceeds this limit, rows are removed from the end and a + "(truncated)" note is appended to avoid cutting mid-row. + + Returns: + A markdown-formatted table string using GitHub table format + """ + if not data: + return "" + + processed_data = [{k: _format_value(v) for k, v in row.items()} for row in data] + full_table = tabulate( + processed_data, headers="keys", tablefmt="github", disable_numparse=True + ) + + if max_length is None or len(full_table) <= max_length: + return full_table + + if max_length <= 0: + return "" + truncation_note = "\n(truncated)" + if max_length <= len(truncation_note): + return "(truncated)"[:max_length] + effective_max = max_length - len(truncation_note) + for row_count in range(len(processed_data) - 1, 0, -1): + table = tabulate( + processed_data[:row_count], + headers="keys", + tablefmt="github", + disable_numparse=True, + ) + if len(table) <= effective_max: + return table + truncation_note + + single_row_table = tabulate( + processed_data[:1], + headers="keys", + tablefmt="github", + disable_numparse=True, + ) + if len(single_row_table) <= effective_max: + return single_row_table + truncation_note + return single_row_table[:effective_max].rstrip() + truncation_note diff --git a/tests/unit/monitor/data_monitoring/alerts/integrations/slack/test_slack_alert_message_builder.py b/tests/unit/monitor/data_monitoring/alerts/integrations/slack/test_slack_alert_message_builder.py index 244f6478a..edc106b7b 100644 --- a/tests/unit/monitor/data_monitoring/alerts/integrations/slack/test_slack_alert_message_builder.py +++ b/tests/unit/monitor/data_monitoring/alerts/integrations/slack/test_slack_alert_message_builder.py @@ -5,6 +5,7 @@ from elementary.monitor.data_monitoring.alerts.integrations.slack.message_builder import ( PreviewIsTooLongError, SlackAlertMessageBuilder, + SlackAlertMessageSchema, ) @@ -173,3 +174,57 @@ def test_add_details_to_slack_alert(): }, sort_keys=True, ) + + +def test_full_width_preview_goes_to_blocks_not_attachments(): + """With full_width=True, preview blocks are validated and added to main blocks.""" + message_builder = SlackAlertMessageBuilder(full_width=True) + block = message_builder.create_header_block("Preview header") + message_builder.add_preview_to_slack_alert([block]) + assert len(message_builder.slack_message["blocks"]) == 5 + assert message_builder.slack_message["blocks"][0] == block + assert message_builder.slack_message["attachments"][0]["blocks"] == [] + + +def test_full_width_details_go_to_blocks_not_attachments(): + """With full_width=True, detail blocks are added to main blocks.""" + message_builder = SlackAlertMessageBuilder(full_width=True) + block = message_builder.create_divider_block() + message_builder.add_details_to_slack_alert([block]) + assert len(message_builder.slack_message["blocks"]) == 1 + assert message_builder.slack_message["blocks"][0] == block + assert message_builder.slack_message["attachments"][0]["blocks"] == [] + + +def test_full_width_get_slack_message_structure(): + """With full_width=True, get_slack_message adds rich_text first, title/preview/details in blocks, and clears attachments.""" + message_builder = SlackAlertMessageBuilder(full_width=True) + title = message_builder.create_header_block("Alert title") + preview_block = message_builder.create_text_section_block("Preview text") + detail_block = message_builder.create_divider_block() + schema = SlackAlertMessageSchema( + title=[title], + preview=[preview_block], + details=[detail_block], + ) + result = message_builder.get_slack_message(alert_schema=schema) + + blocks = result.blocks + valid_rich_text_block = { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": " "}], + } + ], + } + assert blocks[0] == valid_rich_text_block + assert blocks[1] == title + assert blocks[2]["type"] == "divider" + assert blocks[3] == preview_block + # Blocks 4-7 are padding from preview validation + assert blocks[8] == detail_block + assert len(blocks) == 9 + + assert result.attachments == [] diff --git a/tests/unit/utils/test_json_utils.py b/tests/unit/utils/test_json_utils.py new file mode 100644 index 000000000..d8cdacb18 --- /dev/null +++ b/tests/unit/utils/test_json_utils.py @@ -0,0 +1,58 @@ +from elementary.utils.json_utils import list_of_dicts_to_markdown_table + + +def test_list_of_dicts_to_markdown_table_empty(): + assert list_of_dicts_to_markdown_table([]) == "" + + +def test_list_of_dicts_to_markdown_table_single_row(): + result = list_of_dicts_to_markdown_table([{"a": 1, "b": "two"}]) + # tabulate "github" format pads columns; assert header and row content + assert "a" in result and "b" in result + assert "1" in result and "two" in result + assert "|" in result and "-----" in result + + +def test_list_of_dicts_to_markdown_table_multiple_rows(): + data = [ + {"col1": "a", "col2": "b"}, + {"col1": "c", "col2": "d"}, + ] + result = list_of_dicts_to_markdown_table(data) + assert "col1" in result and "col2" in result + assert "a" in result and "b" in result and "c" in result and "d" in result + assert result.count("\n") >= 3 # header, separator, 2 data rows + + +def test_list_of_dicts_to_markdown_table_none_values(): + result = list_of_dicts_to_markdown_table([{"x": None, "y": "ok"}]) + assert "x" in result and "y" in result + assert "ok" in result + # None is formatted as empty string (empty cell between pipes) + assert "|" in result + + +def test_list_of_dicts_to_markdown_table_float_int_like(): + """Floats that are whole numbers are formatted as ints (no scientific notation).""" + result = list_of_dicts_to_markdown_table([{"n": 1.0}, {"n": 2.0}]) + assert "n" in result + assert " 1 " in result or "| 1 " in result + assert " 2 " in result or "| 2 " in result + + +def test_list_of_dicts_to_markdown_table_float_decimal(): + """Decimal floats are formatted without scientific notation.""" + result = list_of_dicts_to_markdown_table([{"x": 1.23456789}]) + assert "1.23456789" in result or "1.2345678" in result + + +def test_list_of_dicts_to_markdown_table_inf_nan(): + """inf and nan are stringified.""" + data = [ + {"v": float("inf")}, + {"v": float("-inf")}, + {"v": float("nan")}, + ] + result = list_of_dicts_to_markdown_table(data) + assert "inf" in result + assert "nan" in result