Skip to content
12 changes: 12 additions & 0 deletions docs/oss/deployment-and-configuration/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <your_slack_token> --slack-channel-name <channel> --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.

---
2 changes: 2 additions & 0 deletions docs/oss/guides/alerts/send-slack-alerts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Make sure to run the following command after your dbt runs and tests:
edr monitor --slack-token <your_slack_token> --slack-channel-name <slack_channel_to_post_at> --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
Expand Down
6 changes: 6 additions & 0 deletions elementary/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion elementary/messages/formats/block_kit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion elementary/messages/formats/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
7 changes: 6 additions & 1 deletion elementary/messages/formats/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
8 changes: 8 additions & 0 deletions elementary/monitor/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <https://www.elementary-data.com/|Elementary>"
),
]
if alert.suppression_interval:
title.extend(
Expand Down Expand Up @@ -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_'}")
Expand All @@ -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 (
Expand All @@ -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()}```"
),
Expand All @@ -220,13 +222,18 @@ def _get_dbt_test_template(
TEST_RESULTS_SAMPLE_FIELD in (alert.alert_fields or DEFAULT_ALERT_FIELDS)
and alert.test_rows_sample
):
# Reserve space for the code fence (``` prefix and ``` suffix = 6 chars)
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}```"
),
Comment on lines +225 to 237
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Slack API maximum blocks per message limit documentation

💡 Result:

Slack’s Block Kit documentation states that a message’s blocks array can include up to 50 blocks per message (and up to 100 blocks in modals or Home tabs). [1]

Docs: Slack Developer Docs → “Blocks” (Block Kit reference). [1]

Sources
[1] Slack Developer Docs — “Blocks” (Block Kit reference): https://docs.slack.dev/reference/block-kit/blocks/


🌐 Web query:

Slack mrkdwn section text field maximum character length limit

💡 Result:

In Slack Block Kit, a section block’s text field (a mrkdwn text object) has a maximum length of 3000 characters. [1][2]

If you’re using section.fields (an array of text objects), each field’s text max is 2000 characters (max 10 fields). [1]


Run end-to-end Slack integration test with large sample payloads before release.

This path combines multiple section blocks with code-fenced sample tables. Slack enforces a 50-block limit per message and 3000-character limit per mrkdwn section text field. Validate that near-limit payloads (with large test result samples) do not trigger invalid_blocks or msg_too_long errors against the actual Slack API.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py` around
lines 225 - 237, The Slack message can exceed Slack limits (50 blocks and 3000
chars per mrkdwn section) when embedding large code-fenced tables; update the
logic around table_max_length / list_of_dicts_to_markdown_table to enforce
truncation so the resulting test_rows_sample_table plus the surrounding "```"
stays <= 3000 chars, and ensure you do not create more than 50 blocks when
building with self.message_builder.create_text_section_block (consider
collapsing or omitting less critical sections). Specifically, adjust the
reserve-for-fence calculation (currently using SectionBlock.text_max_length -
6), enforce a hard cap on test_rows_sample_table length before wrapping it in a
code fence, and add a guard in the message-building flow to skip or summarize
this sample when total blocks would exceed Slack's 50-block limit.

]
)
Expand All @@ -235,7 +242,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:
Expand Down Expand Up @@ -330,8 +339,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_'}")
Expand All @@ -351,21 +363,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 (
Expand All @@ -374,7 +377,7 @@ def _get_elementary_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()}```"
),
Expand Down Expand Up @@ -1194,7 +1197,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,
]
Expand Down
Loading
Loading