diff --git a/src/robusta/integrations/msteams/msteams_msg.py b/src/robusta/integrations/msteams/msteams_msg.py index 06b8c3076..ed5f2cc14 100644 --- a/src/robusta/integrations/msteams/msteams_msg.py +++ b/src/robusta/integrations/msteams/msteams_msg.py @@ -152,12 +152,10 @@ def header_block(self, block: HeaderBlock): # dont include the base 64 images in the total size calculation def _put_text_files_data_up_to_max_limit(self, complete_card_map: map): - curr_images_len = 0 - for element in self.entire_msg: - if isinstance(element, MsTeamsImages): - curr_images_len += element.get_images_len_in_bytes() + images_len = self.__get_images_len() - max_len_left = self.MAX_SIZE_IN_BYTES - (self.__get_current_card_len(complete_card_map) - curr_images_len) + def over_budget() -> int: + return self.MAX_SIZE_IN_BYTES - (self.__get_current_card_len(complete_card_map) - images_len) curr_line = 0 while True: @@ -168,19 +166,92 @@ def _put_text_files_data_up_to_max_limit(self, complete_card_map: map): continue line = lines[len(lines) - curr_line] - max_len_left -= len(line) - if max_len_left < 0: + previous_text = text_element.get_text_from_block() + text_element.set_text_from_block(line + previous_text) + if over_budget() < 0: + # the serialized payload went over budget with this line; revert it + text_element.set_text_from_block(previous_text) return - new_text_value = line + text_element.get_text_from_block() - text_element.set_text_from_block(new_text_value) line_added = True if not line_added: return + def _trim_card_body_up_to_max_limit(self, complete_card_map: map): + # The card body itself (title, markdown blocks, tables, diffs) is never budgeted, + # so a finding with many enrichments can exceed the webhook payload limit and + # Teams rejects it with RequestEntityTooLarge. Truncate body text blocks and + # table rows until the payload (excluding base64 images) fits the limit. + images_len = self.__get_images_len() + + def over_budget() -> int: + return self.MAX_SIZE_IN_BYTES - (self.__get_current_card_len(complete_card_map) - images_len) + + max_len_left = over_budget() + if max_len_left >= 0: + return + + # Trim from the end of the message first, so the title and initial + # context stay intact when there are many enrichments. + for element in reversed(self.entire_msg): + if max_len_left >= 0: + break + if isinstance(element, MsTeamsTextBlock): + text = element.get_text_from_block() + if text: + truncated, _ = self.__truncate_text(text, max_len_left) + element.set_text_from_block(truncated) + max_len_left = over_budget() + elif isinstance(element, MsTeamsTable): + self.__trim_table_rows(element, over_budget) + max_len_left = over_budget() + + def __get_images_len(self) -> int: + return sum( + element.get_images_len_in_bytes() for element in self.entire_msg if isinstance(element, MsTeamsImages) + ) + + @staticmethod + def __json_bytes(text: str) -> int: + return len(json.dumps(text, ensure_ascii=True).encode("utf-8")) + + @staticmethod + def __truncate_text(text: str, max_len_left: int, suffix: str = "\n...\n") -> (str, int): + # max_len_left is negative (over budget). Trim the text so the JSON-serialized + # result (prefix + suffix) frees exactly the needed bytes. JSON escaping (e.g. + # "\n" -> "\\n") and non-ASCII encoding make char-based estimates drift, + # so measure serialized UTF-8 bytes. + freed_needed = -max_len_left + text_json_len = MsTeamsMsg.__json_bytes(text) + suffix_json_len = MsTeamsMsg.__json_bytes(suffix) + prefix_budget = text_json_len - freed_needed - suffix_json_len + if prefix_budget <= 0: + return "", max_len_left + text_json_len + if prefix_budget >= text_json_len: + return text, max_len_left + + lo, hi = 0, len(text) + while lo < hi: + mid = (lo + hi + 1) // 2 + if MsTeamsMsg.__json_bytes(text[:mid]) <= prefix_budget: + lo = mid + else: + hi = mid - 1 + + new_text = text[:lo] + suffix + freed = text_json_len - MsTeamsMsg.__json_bytes(new_text) + return new_text, max_len_left + freed + + def __trim_table_rows(self, table_element: MsTeamsTable, over_budget): + table_map = table_element.get_map_value() + rows = table_map.get("rows", []) + while rows and over_budget() < 0: + rows.pop() + def send(self): try: complete_card_map: dict = MsTeamsCard(self.entire_msg).get_map_value() + self._trim_card_body_up_to_max_limit(complete_card_map) self._put_text_files_data_up_to_max_limit(complete_card_map) response = requests.post(self.webhook_url, json=complete_card_map) @@ -195,4 +266,6 @@ def send(self): @classmethod def __get_current_card_len(cls, complete_card_map: dict): - return len(json.dumps(complete_card_map, ensure_ascii=True, indent=2)) + # Match what the HTTP client actually sends: compact JSON, with + # non-ASCII characters escaped, encoded as UTF-8. + return len(json.dumps(complete_card_map, ensure_ascii=True).encode("utf-8")) diff --git a/tests/test_msteams_msg_size.py b/tests/test_msteams_msg_size.py new file mode 100644 index 000000000..1dbdb6705 --- /dev/null +++ b/tests/test_msteams_msg_size.py @@ -0,0 +1,124 @@ +import json + +from robusta.core.reporting import Finding +from robusta.core.reporting.blocks import MarkdownBlock, TableBlock +from robusta.integrations.msteams.msteams_elements.msteams_card import MsTeamsCard +from robusta.integrations.msteams.msteams_elements.msteams_table import MsTeamsTable +from robusta.integrations.msteams.msteams_msg import MsTeamsMsg + + +def _card_len(msg: MsTeamsMsg) -> int: + # same compact UTF-8 serialization the HTTP client sends + return len(json.dumps(MsTeamsCard(msg.entire_msg).get_map_value(), ensure_ascii=True).encode("utf-8")) + + +def _add_title(msg: MsTeamsMsg): + finding = Finding(title="title", aggregation_key="key", description="short description") + msg.write_title_and_desc(False, finding, "cluster", "account") + + +def test_large_message_body_is_truncated_to_fit_budget(): + msg = MsTeamsMsg(webhook_url="http://example.com", prefer_redirect_to_platform=False) + _add_title(msg) + for i in range(20): + msg.markdown_block(MarkdownBlock(f"block-{i} " + "a" * 2900)) + msg.write_current_section() + + complete_card_map = MsTeamsCard(msg.entire_msg).get_map_value() + assert _card_len(msg) > MsTeamsMsg.MAX_SIZE_IN_BYTES + + msg._trim_card_body_up_to_max_limit(complete_card_map) + + assert _card_len(msg) <= MsTeamsMsg.MAX_SIZE_IN_BYTES + + +def test_small_message_is_not_modified(): + msg = MsTeamsMsg(webhook_url="http://example.com", prefer_redirect_to_platform=False) + _add_title(msg) + msg.markdown_block(MarkdownBlock("small block")) + msg.write_current_section() + + complete_card_map = MsTeamsCard(msg.entire_msg).get_map_value() + before = _card_len(msg) + assert before <= MsTeamsMsg.MAX_SIZE_IN_BYTES + + msg._trim_card_body_up_to_max_limit(complete_card_map) + assert _card_len(msg) == before + + +def test_table_rows_are_trimmed_to_fit_budget(): + msg = MsTeamsMsg(webhook_url="http://example.com", prefer_redirect_to_platform=False) + rows = [[f"cell-{i}" * 100 for _ in range(4)] for i in range(300)] + msg.table(TableBlock(rows=rows, headers=["a", "b", "c", "d"], table_name="events")) + msg.write_current_section() + + complete_card_map = MsTeamsCard(msg.entire_msg).get_map_value() + assert _card_len(msg) > MsTeamsMsg.MAX_SIZE_IN_BYTES + + msg._trim_card_body_up_to_max_limit(complete_card_map) + + assert _card_len(msg) <= MsTeamsMsg.MAX_SIZE_IN_BYTES + + +def test_table_trim_stops_when_budget_is_met(): + # Boundary case: payload exceeds the limit by less than one row's serialized + # size (including the JSON array separator). Removing exactly one row must + # stop the loop instead of dropping an extra row. + msg = MsTeamsMsg(webhook_url="http://example.com", prefer_redirect_to_platform=False) + _add_title(msg) + + filler_rows = [[f"small-{i}" for _ in range(2)] for i in range(30)] + msg.table(TableBlock(rows=filler_rows, headers=["a", "b"], table_name="filler")) + msg.write_current_section() + + table = TableBlock( + rows=[["big-row", "x" * 600] for _ in range(40)], + headers=["a", "b"], + table_name="events", + ) + msg.table(table) + msg.write_current_section() + + complete_card_map = MsTeamsCard(msg.entire_msg).get_map_value() + assert _card_len(msg) > MsTeamsMsg.MAX_SIZE_IN_BYTES + + msg._trim_card_body_up_to_max_limit(complete_card_map) + + assert _card_len(msg) <= MsTeamsMsg.MAX_SIZE_IN_BYTES + + # trimming walks from the end of the message, so the earlier "filler" + # table must be untouched once the budget is met on the "events" table + body = complete_card_map["attachments"][0]["content"]["body"] + tables = [element["rows"] for element in body if element.get("type") == "Table"] + assert len(tables) == 2 + filler_rows_after, events_rows_after = tables + assert len(filler_rows_after) == 31 # 30 rows + header, untouched + assert 2 <= len(events_rows_after) < 41 # header + at least one event row kept + + # the trim must be tight: restoring the next removed event row would + # push the serialized payload back over the budget + removed_rows = table.rows[len(events_rows_after) - 1 :] + next_removed_row = MsTeamsTable(["a", "b"], [removed_rows[0]], None).get_map_value()["rows"][1] + restored_len = _card_len(msg) + len(json.dumps(next_removed_row, ensure_ascii=True).encode("utf-8")) + assert restored_len > MsTeamsMsg.MAX_SIZE_IN_BYTES + + +def test_escaped_and_non_ascii_text_is_trimmed_to_fit_serialized_bytes(): + # JSON escaping ("\n" -> "\\n") and non-ASCII UTF-8 encoding inflate the + # serialized payload beyond the character count, which used to push the + # final request over the Teams webhook limit. + msg = MsTeamsMsg(webhook_url="http://example.com", prefer_redirect_to_platform=False) + _add_title(msg) + for i in range(20): + msg.markdown_block(MarkdownBlock(f"block-{i} \n" + "Ω" * 2900)) + msg.write_current_section() + + complete_card_map = MsTeamsCard(msg.entire_msg).get_map_value() + assert _card_len(msg) > MsTeamsMsg.MAX_SIZE_IN_BYTES + + msg._trim_card_body_up_to_max_limit(complete_card_map) + assert _card_len(msg) <= MsTeamsMsg.MAX_SIZE_IN_BYTES + + # _card_len uses the same compact UTF-8 serialization the HTTP client sends, + # so the assertion above already matches the actual request body. + assert _card_len(msg) == len(json.dumps(complete_card_map, ensure_ascii=True).encode("utf-8"))