From 9b23738b37befc44ba428904e22ef5eb92f297ee Mon Sep 17 00:00:00 2001 From: michrzan Date: Sun, 1 Feb 2026 18:16:14 +0200 Subject: [PATCH 01/10] feat: add --slack-full-width option where test results are shown as a markdown table --- elementary/config/config.py | 6 +++ elementary/messages/formats/block_kit.py | 4 +- elementary/messages/formats/markdown.py | 7 ++- elementary/messages/formats/text.py | 7 ++- elementary/monitor/cli.py | 7 +++ .../alerts/integrations/integrations.py | 2 +- .../integrations/slack/message_builder.py | 22 ++++++++- .../alerts/integrations/slack/slack.py | 45 ++++++++++--------- elementary/utils/json_utils.py | 37 ++++++++++++++- 9 files changed, 109 insertions(+), 28 deletions(-) diff --git a/elementary/config/config.py b/elementary/config/config.py index fbce626c5..2b3021d8b 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, @@ -145,6 +146,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 c86b4b992..2b6fc95b1 100644 --- a/elementary/monitor/cli.py +++ b/elementary/monitor/cli.py @@ -307,6 +307,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, @@ -341,6 +347,7 @@ def monitor( maximum_columns_in_alert_samples, quiet_logs, ssl_ca_bundle, + slack_full_width, ): """ Get alerts on failures in dbt jobs. diff --git a/elementary/monitor/data_monitoring/alerts/integrations/integrations.py b/elementary/monitor/data_monitoring/alerts/integrations/integrations.py index aeba80dae..8e1ccf3c3 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/integrations.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/integrations.py @@ -45,7 +45,7 @@ def get_integration( ) -> Union[BaseMessagingIntegration, BaseIntegration]: if config.has_slack: ssl_context = create_ssl_context(config.ssl_ca_bundle) - 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..023e46c07 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/slack/message_builder.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/slack/message_builder.py @@ -26,18 +26,38 @@ 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: + return self._get_full_width_slack_message(alert_schema) 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) return super().get_slack_message() + def _get_full_width_slack_message( + self, + alert_schema: SlackAlertMessageSchema, + ) -> SlackMessageSchema: + # Add empty rich_text block first to force Slack to render full width + self._add_always_displayed_blocks([{"type": "rich_text", "elements": []}]) + self.add_title_to_slack_alert(alert_schema.title) + # Add preview and details to main blocks instead of attachments + # Skip padding for full-width mode since all content is displayed + if alert_schema.preview: + self._add_always_displayed_blocks(alert_schema.preview) + if alert_schema.details: + self._add_always_displayed_blocks(alert_schema.details) + # Clear attachments for full-width mode + self.slack_message["attachments"] = [] + return super().get_slack_message() + def add_title_to_slack_alert(self, title_blocks: Optional[SlackBlocksType] = None): if title_blocks: title = [*title_blocks, self.create_divider_block()] diff --git a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py index a0e5ce30d..62f44b630 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py @@ -26,6 +26,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 +79,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 +119,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( @@ -186,21 +192,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 +206,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 +217,16 @@ def _get_dbt_test_template( TEST_RESULTS_SAMPLE_FIELD in (alert.alert_fields or DEFAULT_ALERT_FIELDS) and alert.test_rows_sample ): + test_rows_sample_table = list_of_dicts_to_markdown_table( + alert.test_rows_sample + ) result.extend( [ - self.message_builder.create_context_block( - ["*Test results sample*"] + self.message_builder.create_text_section_block( + "*Test results sample*" ), self.message_builder.create_text_section_block( - f"```{alert.test_rows_sample}```" + f"```{test_rows_sample_table}```" ), ] ) @@ -235,7 +235,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: @@ -1194,7 +1196,6 @@ def _create_single_alert_details_blocks( if result: details_blocks.extend( [ - self.message_builder.create_text_section_block(":mag: *Result*"), self.message_builder.create_divider_block(), *result, ] diff --git a/elementary/utils/json_utils.py b/elementary/utils/json_utils.py index d0e3ee3c1..e43246d43 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,36 @@ 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]]) -> str: + """ + Convert a list of dictionaries to a markdown table string. + + Args: + data: List of dictionaries with consistent keys + + 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] + return tabulate( + processed_data, headers="keys", tablefmt="github", disable_numparse=True + ) From fa34bca76921d6827b4428832e428ea97619f707 Mon Sep 17 00:00:00 2001 From: michrzan Date: Mon, 9 Feb 2026 12:42:13 +0000 Subject: [PATCH 02/10] chore: refactor slack message builder to use super() --- .../integrations/slack/message_builder.py | 34 ++++++++----------- elementary/utils/json_utils.py | 4 +-- 2 files changed, 16 insertions(+), 22 deletions(-) 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 023e46c07..8069d797e 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/slack/message_builder.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/slack/message_builder.py @@ -35,27 +35,13 @@ def get_slack_message( alert_schema: SlackAlertMessageSchema, ) -> SlackMessageSchema: if self.full_width: - return self._get_full_width_slack_message(alert_schema) + # Add empty rich_text block first to force Slack to render full width + self._add_always_displayed_blocks([{"type": "rich_text", "elements": []}]) 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) - return super().get_slack_message() - - def _get_full_width_slack_message( - self, - alert_schema: SlackAlertMessageSchema, - ) -> SlackMessageSchema: - # Add empty rich_text block first to force Slack to render full width - self._add_always_displayed_blocks([{"type": "rich_text", "elements": []}]) - self.add_title_to_slack_alert(alert_schema.title) - # Add preview and details to main blocks instead of attachments - # Skip padding for full-width mode since all content is displayed - if alert_schema.preview: - self._add_always_displayed_blocks(alert_schema.preview) - if alert_schema.details: - self._add_always_displayed_blocks(alert_schema.details) - # Clear attachments for full-width mode - self.slack_message["attachments"] = [] + 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): @@ -66,7 +52,11 @@ 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: + if not preview_blocks: + return + if self.full_width: + self._add_always_displayed_blocks(preview_blocks) + else: validated_preview_blocks = self._validate_preview_blocks(preview_blocks) self._add_blocks_as_attachments(validated_preview_blocks) @@ -74,7 +64,11 @@ 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/utils/json_utils.py b/elementary/utils/json_utils.py index e43246d43..cf224be15 100644 --- a/elementary/utils/json_utils.py +++ b/elementary/utils/json_utils.py @@ -114,10 +114,10 @@ def _format_value(value: Any) -> str: def list_of_dicts_to_markdown_table(data: List[Dict[str, Any]]) -> str: """ - Convert a list of dictionaries to a markdown table string. + Convert a list of dictionaries with consistent keys to a markdown table string. Args: - data: List of dictionaries with consistent keys + data: List of dictionaries Returns: A markdown-formatted table string using GitHub table format From 153f5309ed35a99ace011ae5a72c43bf1d2a30eb Mon Sep 17 00:00:00 2001 From: michrzan Date: Mon, 9 Feb 2026 13:26:19 +0000 Subject: [PATCH 03/10] chore: update docs --- docs/oss/deployment-and-configuration/slack.mdx | 12 ++++++++++++ docs/oss/guides/alerts/send-slack-alerts.mdx | 2 ++ 2 files changed, 14 insertions(+) diff --git a/docs/oss/deployment-and-configuration/slack.mdx b/docs/oss/deployment-and-configuration/slack.mdx index 18079bc65..9b45b5c8c 100644 --- a/docs/oss/deployment-and-configuration/slack.mdx +++ b/docs/oss/deployment-and-configuration/slack.mdx @@ -53,3 +53,15 @@ The alert format is: ``` --- + +## Full-width alerts + +By default, Slack alerts use a narrower layout with some content in attachments. To use the full message width and show test results as a markdown table in the main message body, pass the flag when running the monitor: + +```shell +edr monitor --slack-token --slack-channel-name --slack-full-width +``` + +With `--slack-full-width`, alerts are sent using Slack Block Kit in the main message body instead of attachments, so the full channel width is used and test result samples appear as formatted markdown tables. + +--- diff --git a/docs/oss/guides/alerts/send-slack-alerts.mdx b/docs/oss/guides/alerts/send-slack-alerts.mdx index 79cd4e635..a8052b0d3 100644 --- a/docs/oss/guides/alerts/send-slack-alerts.mdx +++ b/docs/oss/guides/alerts/send-slack-alerts.mdx @@ -24,6 +24,8 @@ Make sure to run the following command after your dbt runs and tests: edr monitor --slack-token --slack-channel-name --group-by [table | alert] ``` +Add `--slack-full-width` to use the full message width and show test results as markdown tables. See [Slack setup - Full-width alerts](/oss/deployment-and-configuration/slack#full-width-alerts). + Or just `edr monitor` if you used `config.yml`. Please note that when you specify the --slack-channel-name, it's the default channel name to which all the alerts will be sent that are not attributed to any custom channel. Therefore, if you execute several `edr monitor` commands at the same time with different `slack-channel-name` arguments, they can From 117f19f7c80f7f96256b5dcca4f2e92d5eedf420 Mon Sep 17 00:00:00 2001 From: michrzan Date: Mon, 9 Feb 2026 17:25:27 +0000 Subject: [PATCH 04/10] feat: unit tests --- .../slack/test_slack_alert_message_builder.py | 45 ++++++++++++++ tests/unit/utils/test_json_utils.py | 58 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 tests/unit/utils/test_json_utils.py 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..65bd7b753 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,47 @@ 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 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"]) == 1 + 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 + assert len(blocks) >= 4 + assert blocks[0] == {"type": "rich_text", "elements": []} + assert blocks[1] == title + assert blocks[2]["type"] == "divider" + assert blocks[3] == preview_block + assert blocks[4] == detail_block + + 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 From 7d2a4ff4433bfc45db1e67ae9d00909e6721ec3f Mon Sep 17 00:00:00 2001 From: michrzan Date: Mon, 9 Feb 2026 17:33:10 +0000 Subject: [PATCH 05/10] chore: docs --- .../alerts/integrations/slack/message_builder.py | 3 ++- .../integrations/slack/test_slack_alert_message_builder.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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 8069d797e..61e81ed34 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/slack/message_builder.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/slack/message_builder.py @@ -35,7 +35,8 @@ def get_slack_message( alert_schema: SlackAlertMessageSchema, ) -> SlackMessageSchema: if self.full_width: - # Add empty rich_text block first to force Slack to render full width + # Empty rich_text block forces Slack to use full message width for following + # blocks instead of the narrower attachment-style layout. self._add_always_displayed_blocks([{"type": "rich_text", "elements": []}]) self.add_title_to_slack_alert(alert_schema.title) self.add_preview_to_slack_alert(alert_schema.preview) 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 65bd7b753..b7edacb98 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 @@ -210,7 +210,7 @@ def test_full_width_get_slack_message_structure(): result = message_builder.get_slack_message(alert_schema=schema) blocks = result.blocks - assert len(blocks) >= 4 + assert len(blocks) >= 5 assert blocks[0] == {"type": "rich_text", "elements": []} assert blocks[1] == title assert blocks[2]["type"] == "divider" From a611b9f3ca2a8e4c7dfa22b36a353ed620fb69d3 Mon Sep 17 00:00:00 2001 From: michrzan Date: Tue, 10 Feb 2026 13:56:01 +0000 Subject: [PATCH 06/10] fix: sync emojis --- .../alerts/integrations/slack/slack.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py index 62f44b630..62d4c525f 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 @@ -171,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_'}") @@ -223,7 +228,7 @@ def _get_dbt_test_template( result.extend( [ self.message_builder.create_text_section_block( - "*Test results sample*" + f"{ICON_TO_UNICODE[Icon.MAGNIFYING_GLASS]} *Test results sample*" ), self.message_builder.create_text_section_block( f"```{test_rows_sample_table}```" @@ -332,8 +337,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_'}") @@ -1196,6 +1204,9 @@ def _create_single_alert_details_blocks( if result: details_blocks.extend( [ + self.message_builder.create_text_section_block( + f"{ICON_TO_UNICODE[Icon.INFO]} *Details*" + ), self.message_builder.create_divider_block(), *result, ] From 5ec85bb0c68df70c7cd962e7bc6bb1838bdf2ea0 Mon Sep 17 00:00:00 2001 From: Itamar Hartstein Date: Sun, 1 Mar 2026 14:02:50 +0200 Subject: [PATCH 07/10] fix: address PR review comments for --slack-full-width - Replace invalid empty rich_text block with valid non-empty structure (Slack API rejects empty elements arrays) - Add max_length param to list_of_dicts_to_markdown_table for graceful row-by-row truncation instead of cutting mid-row - Capitalize "markdown" to "Markdown" in docs (proper noun) - Run preview validation in full-width mode for consistent safety bounds - Update tests for valid rich_text block and exact assertions Made-with: Cursor --- .../deployment-and-configuration/slack.mdx | 4 +-- docs/oss/guides/alerts/send-slack-alerts.mdx | 2 +- .../integrations/slack/message_builder.py | 23 ++++++++++--- .../alerts/integrations/slack/slack.py | 3 +- elementary/utils/json_utils.py | 34 +++++++++++++++++-- .../slack/test_slack_alert_message_builder.py | 20 ++++++++--- 6 files changed, 70 insertions(+), 16 deletions(-) diff --git a/docs/oss/deployment-and-configuration/slack.mdx b/docs/oss/deployment-and-configuration/slack.mdx index 9b45b5c8c..870596fb3 100644 --- a/docs/oss/deployment-and-configuration/slack.mdx +++ b/docs/oss/deployment-and-configuration/slack.mdx @@ -56,12 +56,12 @@ The alert format is: ## Full-width alerts -By default, Slack alerts use a narrower layout with some content in attachments. To use the full message width and show test results as a markdown table in the main message body, pass the flag when running the monitor: +By default, Slack alerts use a narrower layout with some content in attachments. To use the full message width and show test results as a Markdown table in the main message body, pass the flag when running the monitor: ```shell edr monitor --slack-token --slack-channel-name --slack-full-width ``` -With `--slack-full-width`, alerts are sent using Slack Block Kit in the main message body instead of attachments, so the full channel width is used and test result samples appear as formatted markdown tables. +With `--slack-full-width`, alerts are sent using Slack Block Kit in the main message body instead of attachments, so the full channel width is used and test result samples appear as formatted Markdown tables. --- diff --git a/docs/oss/guides/alerts/send-slack-alerts.mdx b/docs/oss/guides/alerts/send-slack-alerts.mdx index a8052b0d3..8b6e6ff32 100644 --- a/docs/oss/guides/alerts/send-slack-alerts.mdx +++ b/docs/oss/guides/alerts/send-slack-alerts.mdx @@ -24,7 +24,7 @@ Make sure to run the following command after your dbt runs and tests: edr monitor --slack-token --slack-channel-name --group-by [table | alert] ``` -Add `--slack-full-width` to use the full message width and show test results as markdown tables. See [Slack setup - Full-width alerts](/oss/deployment-and-configuration/slack#full-width-alerts). +Add `--slack-full-width` to use the full message width and show test results as Markdown tables. See [Slack setup - Full-width alerts](/oss/deployment-and-configuration/slack#full-width-alerts). Or just `edr monitor` if you used `config.yml`. Please note that when you specify the --slack-channel-name, it's the default channel name to which all the alerts will be sent that are not attributed to any custom channel. Therefore, 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 61e81ed34..b167daee5 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/slack/message_builder.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/slack/message_builder.py @@ -35,9 +35,22 @@ def get_slack_message( alert_schema: SlackAlertMessageSchema, ) -> SlackMessageSchema: if self.full_width: - # Empty rich_text block forces Slack to use full message width for following - # blocks instead of the narrower attachment-style layout. - self._add_always_displayed_blocks([{"type": "rich_text", "elements": []}]) + # 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) @@ -55,10 +68,10 @@ def add_preview_to_slack_alert( ): if not preview_blocks: return + validated_preview_blocks = self._validate_preview_blocks(preview_blocks) if self.full_width: - self._add_always_displayed_blocks(preview_blocks) + self._add_always_displayed_blocks(validated_preview_blocks) else: - validated_preview_blocks = self._validate_preview_blocks(preview_blocks) self._add_blocks_as_attachments(validated_preview_blocks) def add_details_to_slack_alert( diff --git a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py index 62d4c525f..52e9591f0 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py @@ -222,8 +222,9 @@ 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 + alert.test_rows_sample, max_length=table_max_length ) result.extend( [ diff --git a/elementary/utils/json_utils.py b/elementary/utils/json_utils.py index cf224be15..121d7de9a 100644 --- a/elementary/utils/json_utils.py +++ b/elementary/utils/json_utils.py @@ -112,12 +112,17 @@ def _format_value(value: Any) -> str: return str(value) -def list_of_dicts_to_markdown_table(data: List[Dict[str, Any]]) -> str: +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 @@ -126,6 +131,31 @@ def list_of_dicts_to_markdown_table(data: List[Dict[str, Any]]) -> str: return "" processed_data = [{k: _format_value(v) for k, v in row.items()} for row in data] - return tabulate( + 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 + + truncation_note = "\n(truncated)" + 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 effective_max <= 0 or 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 b7edacb98..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 @@ -177,11 +177,11 @@ def test_add_details_to_slack_alert(): def test_full_width_preview_goes_to_blocks_not_attachments(): - """With full_width=True, preview blocks are added to main blocks.""" + """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"]) == 1 + assert len(message_builder.slack_message["blocks"]) == 5 assert message_builder.slack_message["blocks"][0] == block assert message_builder.slack_message["attachments"][0]["blocks"] == [] @@ -210,11 +210,21 @@ def test_full_width_get_slack_message_structure(): result = message_builder.get_slack_message(alert_schema=schema) blocks = result.blocks - assert len(blocks) >= 5 - assert blocks[0] == {"type": "rich_text", "elements": []} + 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 - assert blocks[4] == detail_block + # Blocks 4-7 are padding from preview validation + assert blocks[8] == detail_block + assert len(blocks) == 9 assert result.attachments == [] From 3e54e29cf41cf535cb628fac228e0038abfd44fa Mon Sep 17 00:00:00 2001 From: Itamar Hartstein Date: Sun, 1 Mar 2026 14:11:10 +0200 Subject: [PATCH 08/10] chore: remove docs changes (moved to separate PR #2134) Docs for --slack-full-width are handled in a separate PR targeting the docs branch. Made-with: Cursor --- docs/oss/deployment-and-configuration/slack.mdx | 12 ------------ docs/oss/guides/alerts/send-slack-alerts.mdx | 2 -- 2 files changed, 14 deletions(-) diff --git a/docs/oss/deployment-and-configuration/slack.mdx b/docs/oss/deployment-and-configuration/slack.mdx index 870596fb3..18079bc65 100644 --- a/docs/oss/deployment-and-configuration/slack.mdx +++ b/docs/oss/deployment-and-configuration/slack.mdx @@ -53,15 +53,3 @@ The alert format is: ``` --- - -## Full-width alerts - -By default, Slack alerts use a narrower layout with some content in attachments. To use the full message width and show test results as a Markdown table in the main message body, pass the flag when running the monitor: - -```shell -edr monitor --slack-token --slack-channel-name --slack-full-width -``` - -With `--slack-full-width`, alerts are sent using Slack Block Kit in the main message body instead of attachments, so the full channel width is used and test result samples appear as formatted Markdown tables. - ---- diff --git a/docs/oss/guides/alerts/send-slack-alerts.mdx b/docs/oss/guides/alerts/send-slack-alerts.mdx index 8b6e6ff32..79cd4e635 100644 --- a/docs/oss/guides/alerts/send-slack-alerts.mdx +++ b/docs/oss/guides/alerts/send-slack-alerts.mdx @@ -24,8 +24,6 @@ Make sure to run the following command after your dbt runs and tests: edr monitor --slack-token --slack-channel-name --group-by [table | alert] ``` -Add `--slack-full-width` to use the full message width and show test results as Markdown tables. See [Slack setup - Full-width alerts](/oss/deployment-and-configuration/slack#full-width-alerts). - Or just `edr monitor` if you used `config.yml`. Please note that when you specify the --slack-channel-name, it's the default channel name to which all the alerts will be sent that are not attributed to any custom channel. Therefore, if you execute several `edr monitor` commands at the same time with different `slack-channel-name` arguments, they can From 5446b6bd2cfd837e6067c08ad7deca27acf47853 Mon Sep 17 00:00:00 2001 From: Itamar Hartstein Date: Sun, 1 Mar 2026 14:13:56 +0200 Subject: [PATCH 09/10] fix: guard against very small max_length in table truncation Handle edge cases where max_length is <= 0 or smaller than the truncation note itself. Made-with: Cursor --- elementary/utils/json_utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/elementary/utils/json_utils.py b/elementary/utils/json_utils.py index 121d7de9a..9779b0edb 100644 --- a/elementary/utils/json_utils.py +++ b/elementary/utils/json_utils.py @@ -138,7 +138,11 @@ def list_of_dicts_to_markdown_table( 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( @@ -156,6 +160,6 @@ def list_of_dicts_to_markdown_table( tablefmt="github", disable_numparse=True, ) - if effective_max <= 0 or len(single_row_table) <= effective_max: + if len(single_row_table) <= effective_max: return single_row_table + truncation_note return single_row_table[:effective_max].rstrip() + truncation_note From 1eb6672636cc688ef2d4d9a5875fc100a12ce958 Mon Sep 17 00:00:00 2001 From: michrzan Date: Tue, 31 Mar 2026 16:38:22 +0100 Subject: [PATCH 10/10] fix: align elementary test description rendering with dbt test template --- .../alerts/integrations/slack/slack.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py index 52e9591f0..8c3c96a08 100644 --- a/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py +++ b/elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py @@ -362,21 +362,12 @@ def _get_elementary_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 (