Skip to content
Open
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,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}```"
),
]
)
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(".")
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
Loading
Loading