Skip to content
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 @@ -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(
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
7 changes: 7 additions & 0 deletions elementary/monitor/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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"] = []
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
Comment on lines +69 to 75
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

Skip attachment-era preview validation in full_width mode.

Line 71 still runs _validate_preview_blocks() before the full_width branch. That keeps the old 5-block cap and adds padding blocks even when the preview is rendered in top-level blocks, so full-width alerts can still raise PreviewIsTooLongError and end up with extra blank space.

Suggested fix
     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)
+        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)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
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)
🤖 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/message_builder.py`
around lines 69 - 75, The preview block validation (_validate_preview_blocks) is
being called unconditionally before branching on full_width, which enforces the
attachment-era 5-block cap and padding even for top-level blocks; change the
logic so that when self.full_width is True you skip calling
_validate_preview_blocks and directly call
_add_always_displayed_blocks(preview_blocks), and only call
_validate_preview_blocks(preview_blocks) when taking the else branch that calls
_add_blocks_as_attachments(validated_preview_blocks); remove or relocate the
earlier unconditional call to _validate_preview_blocks to avoid raising
PreviewIsTooLongError or adding padding for full-width previews.


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,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}```"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
),
]
)
Expand All @@ -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:
Expand Down Expand Up @@ -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_'}")
Expand Down Expand Up @@ -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,
]
Expand Down
71 changes: 70 additions & 1 deletion elementary/utils/json_utils.py
Original file line number Diff line number Diff line change
@@ -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]]):
Expand Down Expand Up @@ -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(".")
Comment on lines +105 to +111
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
for value in [1e-11, 1e-12, -1e-12]:
    rendered = f"{value:.10f}".rstrip("0").rstrip(".")
    print(f"{value!r} -> {rendered!r}")
PY

Repository: elementary-data/elementary

Length of output: 110


🏁 Script executed:

# First, check if the file exists and inspect the relevant lines
find . -name "json_utils.py" -type f

Repository: elementary-data/elementary

Length of output: 102


🏁 Script executed:

# Check the file size to understand scope
wc -l elementary/utils/json_utils.py 2>/dev/null || echo "File not found in expected location"

Repository: elementary-data/elementary

Length of output: 104


🏁 Script executed:

# Search for the _format_value function to understand context
rg "_format_value" --type py -B 3 -A 15

Repository: elementary-data/elementary

Length of output: 2498


🏁 Script executed:

# Search for uses of list_of_dicts_to_markdown_table to understand impact
rg "list_of_dicts_to_markdown_table|_format_value" --type py

Repository: elementary-data/elementary

Length of output: 2001


🏁 Script executed:

# Search for test files related to json_utils
fd -e "test*.py" -o -name "*test.py" | xargs grep -l "json_utils\|_format_value\|markdown_table" 2>/dev/null || echo "No matching tests found"

Repository: elementary-data/elementary

Length of output: 341


🏁 Script executed:

# Check if there are any references to this in Slack alert or table rendering code
rg "Slack|alert|markdown|table" elementary/utils/json_utils.py -A 2 -B 2

Repository: elementary-data/elementary

Length of output: 1823


🏁 Script executed:

# Look at the actual test file to see what float cases are tested
cat -n tests/unit/utils/test_json_utils.py

Repository: elementary-data/elementary

Length of output: 2574


🏁 Script executed:

# Run the existing tests to see if they pass
grep -A 20 "test_list_of_dicts_to_markdown_table_float" tests/unit/utils/test_json_utils.py

Repository: elementary-data/elementary

Length of output: 964


🏁 Script executed:

# Verify the impact by checking where list_of_dicts_to_markdown_table is used in the Slack integration
rg "test_rows_sample_table" elementary/monitor/data_monitoring/alerts/integrations/slack/slack.py -B 5 -A 5

Repository: elementary-data/elementary

Length of output: 857


🏁 Script executed:

# Check if there are any recent fixes or discussions about small float handling
rg "1e-1[0-2]|scientific|notation" elementary/utils/json_utils.py

Repository: elementary-data/elementary

Length of output: 206


Small non-zero floats collapse to "0" in Slack alert tables.

Line 111 uses f"{value:.10f}" which rounds values smaller than 1e-10 to zero. For example, 1e-12 becomes "0" and -1e-12 becomes "-0". This affects test_rows_sample tables in Slack alerts, causing incorrect data to be displayed to users.

The existing test suite does not cover this edge case. Add a regression test for small float values (e.g., 1e-11, 1e-12) to prevent future regressions.

Possible fix direction
+from decimal import Decimal
...
     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(".")
+        normalized = format(Decimal(str(value)), "f").rstrip("0").rstrip(".")
+        return "0" if normalized in {"", "-0"} else normalized
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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(".")
if isinstance(value, float):
if math.isinf(value) or math.isnan(value):
return str(value)
normalized = format(Decimal(str(value)), "f").rstrip("0").rstrip(".")
return "0" if normalized in {"", "-0"} else normalized
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@elementary/utils/json_utils.py` around lines 105 - 111, The float formatting
collapses tiny non-zero values to "0" by using f"{value:.10f}"; in
elementary/utils/json_utils.py update the float branch (the block that currently
checks isinstance(value, float) and uses f"{value:.10f}") to format with
higher-precision general format (e.g., format(value, ".17g")) so very small
values become "1e-12" instead of "0", and ensure you normalize exact zero to "0"
(avoid "-0" by checking value == 0.0); also keep the existing int-like
conversion (value == int(value) and abs(value) < 1e15) behavior and add
regression tests covering small floats (1e-11, 1e-12, -1e-12) for the code path
used by test_rows_sample Slack alert generation.

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):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Loading
Loading