From f48140a3496b0fbe44ca0f558081252d3252e159 Mon Sep 17 00:00:00 2001 From: Anshul Bisht Date: Sat, 15 Aug 2026 13:36:16 +0530 Subject: [PATCH 1/4] Fix MS Teams RequestEntityTooLarge by trimming card body to size limit Fixes #2111 The MS Teams sink enforces MAX_SIZE_IN_BYTES only on text file attachments, but the card body itself (title, markdown blocks, tables) is never budgeted. Findings with many enrichments exceed the webhook payload limit and Teams rejects them with RequestEntityTooLarge. Trim body text blocks and table rows from the end of the message until the JSON-serialized payload (excluding base64 images) fits the budget. --- .../integrations/msteams/msteams_msg.py | 68 +++++++++++++++++++ tests/test_msteams_msg_size.py | 58 ++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/test_msteams_msg_size.py diff --git a/src/robusta/integrations/msteams/msteams_msg.py b/src/robusta/integrations/msteams/msteams_msg.py index 06b8c3076..c3d7063d2 100644 --- a/src/robusta/integrations/msteams/msteams_msg.py +++ b/src/robusta/integrations/msteams/msteams_msg.py @@ -178,9 +178,77 @@ def _put_text_files_data_up_to_max_limit(self, complete_card_map: map): 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, max_len_left) + 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 __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") makes char-based estimates drift, so measure serialized bytes. + freed_needed = -max_len_left + text_json_len = len(json.dumps(text)) + suffix_json_len = len(json.dumps(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 len(json.dumps(text[:mid])) <= prefix_budget: + lo = mid + else: + hi = mid - 1 + + new_text = text[:lo] + suffix + freed = text_json_len - len(json.dumps(new_text)) + return new_text, max_len_left + freed + + def __trim_table_rows(self, table_element: MsTeamsTable, max_len_left: int): + table_map = table_element.get_map_value() + rows = table_map.get("rows", []) + while rows and max_len_left < 0: + removed_row = rows.pop() + max_len_left += len(json.dumps(removed_row, ensure_ascii=True)) + 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) diff --git a/tests/test_msteams_msg_size.py b/tests/test_msteams_msg_size.py new file mode 100644 index 000000000..35e62ba66 --- /dev/null +++ b/tests/test_msteams_msg_size.py @@ -0,0 +1,58 @@ +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_msg import MsTeamsMsg + + +def _card_len(msg: MsTeamsMsg) -> int: + return len(json.dumps(MsTeamsCard(msg.entire_msg).get_map_value(), ensure_ascii=True, indent=2)) + + +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 From 99e380ff95ecab42522bc18058591dcb0d39f981 Mon Sep 17 00:00:00 2001 From: Anshul Bisht Date: Sat, 15 Aug 2026 17:41:46 +0530 Subject: [PATCH 2/4] Measure msteams payload size with compact UTF-8 serialization Address review feedback: the trim logic measured indented JSON and character counts, while requests.post(json=...) sends compact UTF-8 bytes. Escaped or non-ASCII text could still exceed the webhook limit. Now all budget checks use the same serialization as the HTTP client, and the text-file line fill reverts any line that pushes the payload over budget. Added a regression test with newlines and non-ASCII text. --- .../integrations/msteams/msteams_msg.py | 38 +++++++++++-------- tests/test_msteams_msg_size.py | 24 +++++++++++- 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/robusta/integrations/msteams/msteams_msg.py b/src/robusta/integrations/msteams/msteams_msg.py index c3d7063d2..c09e7dcb0 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,11 +166,12 @@ 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: @@ -212,14 +211,19 @@ def __get_images_len(self) -> int: 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") makes char-based estimates drift, so measure serialized bytes. + # "\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 = len(json.dumps(text)) - suffix_json_len = len(json.dumps(suffix)) + 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 @@ -229,13 +233,13 @@ def __truncate_text(text: str, max_len_left: int, suffix: str = "\n...\n") -> (s lo, hi = 0, len(text) while lo < hi: mid = (lo + hi + 1) // 2 - if len(json.dumps(text[:mid])) <= prefix_budget: + if MsTeamsMsg.__json_bytes(text[:mid]) <= prefix_budget: lo = mid else: hi = mid - 1 new_text = text[:lo] + suffix - freed = text_json_len - len(json.dumps(new_text)) + freed = text_json_len - MsTeamsMsg.__json_bytes(new_text) return new_text, max_len_left + freed def __trim_table_rows(self, table_element: MsTeamsTable, max_len_left: int): @@ -243,7 +247,7 @@ def __trim_table_rows(self, table_element: MsTeamsTable, max_len_left: int): rows = table_map.get("rows", []) while rows and max_len_left < 0: removed_row = rows.pop() - max_len_left += len(json.dumps(removed_row, ensure_ascii=True)) + max_len_left += len(json.dumps(removed_row, ensure_ascii=True).encode("utf-8")) def send(self): try: @@ -263,4 +267,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 index 35e62ba66..dc070dddd 100644 --- a/tests/test_msteams_msg_size.py +++ b/tests/test_msteams_msg_size.py @@ -7,7 +7,8 @@ def _card_len(msg: MsTeamsMsg) -> int: - return len(json.dumps(MsTeamsCard(msg.entire_msg).get_map_value(), ensure_ascii=True, indent=2)) + # 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): @@ -56,3 +57,24 @@ def test_table_rows_are_trimmed_to_fit_budget(): msg._trim_card_body_up_to_max_limit(complete_card_map) assert _card_len(msg) <= 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")) From f1791c0d99672fe2e335ed7dd31682d5cd109b8e Mon Sep 17 00:00:00 2001 From: Anshul Bisht Date: Sat, 15 Aug 2026 18:03:24 +0530 Subject: [PATCH 3/4] Recompute table-row budget after each removal The table trim loop estimated the freed bytes per row, which ignored the JSON array separator. When the payload was over budget by a single byte, the loop could remove one extra row. Pass over_budget into the trim helper and recheck the real serialized size after each pop. --- .../integrations/msteams/msteams_msg.py | 9 +++-- tests/test_msteams_msg_size.py | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/robusta/integrations/msteams/msteams_msg.py b/src/robusta/integrations/msteams/msteams_msg.py index c09e7dcb0..ed5f2cc14 100644 --- a/src/robusta/integrations/msteams/msteams_msg.py +++ b/src/robusta/integrations/msteams/msteams_msg.py @@ -203,7 +203,7 @@ def over_budget() -> int: element.set_text_from_block(truncated) max_len_left = over_budget() elif isinstance(element, MsTeamsTable): - self.__trim_table_rows(element, max_len_left) + self.__trim_table_rows(element, over_budget) max_len_left = over_budget() def __get_images_len(self) -> int: @@ -242,12 +242,11 @@ def __truncate_text(text: str, max_len_left: int, suffix: str = "\n...\n") -> (s freed = text_json_len - MsTeamsMsg.__json_bytes(new_text) return new_text, max_len_left + freed - def __trim_table_rows(self, table_element: MsTeamsTable, max_len_left: int): + 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 max_len_left < 0: - removed_row = rows.pop() - max_len_left += len(json.dumps(removed_row, ensure_ascii=True).encode("utf-8")) + while rows and over_budget() < 0: + rows.pop() def send(self): try: diff --git a/tests/test_msteams_msg_size.py b/tests/test_msteams_msg_size.py index dc070dddd..6d47f119f 100644 --- a/tests/test_msteams_msg_size.py +++ b/tests/test_msteams_msg_size.py @@ -59,6 +59,42 @@ def test_table_rows_are_trimmed_to_fit_budget(): 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 1 <= len(events_rows_after) < 41 # header + at least one row kept + + 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 From d01fba9567a9441f9d4fb67d2a7c47beb989cfcb Mon Sep 17 00:00:00 2001 From: Anshul Bisht Date: Sat, 15 Aug 2026 18:30:51 +0530 Subject: [PATCH 4/4] Strengthen table-trim boundary assertions in tests Verify the trim keeps at least one event row, and that restoring the next removed row would push the serialized payload back over the limit. --- tests/test_msteams_msg_size.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_msteams_msg_size.py b/tests/test_msteams_msg_size.py index 6d47f119f..1dbdb6705 100644 --- a/tests/test_msteams_msg_size.py +++ b/tests/test_msteams_msg_size.py @@ -3,6 +3,7 @@ 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 @@ -92,7 +93,14 @@ def test_table_trim_stops_when_budget_is_met(): assert len(tables) == 2 filler_rows_after, events_rows_after = tables assert len(filler_rows_after) == 31 # 30 rows + header, untouched - assert 1 <= len(events_rows_after) < 41 # header + at least one row kept + 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():