Skip to content

Commit 682556b

Browse files
aantnAvi-Robustaarikalon1
authored
New custom slack sink, slack_sink_preview (#1789)
* WIP * Update sender.py * Update sender.py * Update sender.py * Update sender.py * Update sender.py * Update sender.py * Update sender.py * Update sender.py * incorporate feedback from igor * add templating * remove unused code * add logs * simplify code - always use jinja * add legacy option * bugfix sending to slack * fixing template * refactoring code * saving progress * added tests * refactoring changes * misc changes * changess * working version * refactor send finding to slack * added space * removing unneeded code atm * refactor * fixing * docs update * updated tests * bugfixes and header support * fixed mentions * added channel override test rename test preview pytest fix test fix test fix update slack_utils pytest changes attempting to debug slack test fixing pytest preview sink * refactoring, removing useless changes * refactoring sender * remove unneeded code * added additional features added hide_buttons, added hide_enrichments does not show description on custom template * changed manual tests * add aggregation key * pr changes * added fingerprint to template --------- Co-authored-by: avi robusta <avi@robusta.dev> Co-authored-by: arik <alon.arik@gmail.com>
1 parent fd44578 commit 682556b

13 files changed

Lines changed: 1325 additions & 13 deletions

File tree

docs/configuration/sinks/slack.rst

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,118 @@ your own. This is not recommended for most companies due to the added complexity
181181

182182
When using a custom Slack app, callback buttons are not supported due to complexities in how Slack handles incoming
183183
messages. :ref:`Contact us if you need assistance. <Getting Support>`
184+
185+
186+
Message Templating
187+
-------------------------------------------------------------------
188+
189+
Slack messages can be customized using Jinja2 templates. Robusta includes default templates that match the standard format, but you can override them for custom formatting.
190+
191+
To use custom templates change your `slack_sink` to `slack_sink_preview`, and add your templates to the ``slack_custom_templates`` parameter:
192+
193+
.. code-block:: yaml
194+
195+
sinksConfig:
196+
- slack_sink_preview:
197+
api_key: xoxb-198...
198+
name: preview_slack_sink
199+
slack_channel: demo-slack-preview
200+
slack_custom_templates:
201+
custom_template.j2: |-
202+
{
203+
"type": "header",
204+
"text": {
205+
"type": "plain_text",
206+
"text": "Custom Alert Format:\n {{ status_emoji }} [{{ status_text }}] {{ title }}",
207+
"emoji": true
208+
}
209+
}
210+
211+
{
212+
"type": "section",
213+
"text": {
214+
"type": "mrkdwn",
215+
"text": "{{ status_emoji }} *[{{ status_text }}] {{ title }}*{% if mention %} {{ mention }}{% endif %}"
216+
}
217+
}
218+
219+
{
220+
"type": "divider"
221+
}
222+
223+
{
224+
"type": "section",
225+
"fields": [
226+
{
227+
"type": "mrkdwn",
228+
"text": "*Type:* {{ alert_type }}"
229+
},
230+
{
231+
"type": "mrkdwn",
232+
"text": "*Severity:* {{ severity_emoji }} {{ severity }}"
233+
},
234+
{
235+
"type": "mrkdwn",
236+
"text": "*Cluster:* {{ cluster_name }}"
237+
}
238+
{% if resource_text %}
239+
,
240+
{
241+
"type": "mrkdwn",
242+
"text": "*Resource:*\\n{{ resource_text }}"
243+
}
244+
{% endif %}
245+
]
246+
}
247+
248+
{
249+
"type": "section",
250+
"text": {
251+
"type": "mrkdwn",
252+
"text": "{% if labels %}*Labels:*\\n\\n{% for key, value in labels.items() %}• *{{ key }}*: {{ value }}\\n\\n{% endfor %}{% else %}*Labels:* _None_{% endif %}"
253+
}
254+
}
255+
256+
Templates use Slack's Block Kit format and must generate valid JSON. Each template block is separated by double newlines (``\n\n``).
257+
258+
Available template variables:
259+
260+
+-----------------------------+-------------------------------------------------------------+
261+
| Variable | Description |
262+
+=============================+=============================================================+
263+
| ``title`` | The alert title |
264+
+-----------------------------+-------------------------------------------------------------+
265+
| ``description`` | The alert description |
266+
+-----------------------------+-------------------------------------------------------------+
267+
| ``status_text`` | "Firing" or "Resolved" |
268+
+-----------------------------+-------------------------------------------------------------+
269+
| ``status_emoji`` | "⚠️" (for firing) or "✅" (for resolved) |
270+
+-----------------------------+-------------------------------------------------------------+
271+
| ``severity`` | Alert severity (e.g., "Warning", "Critical") |
272+
+-----------------------------+-------------------------------------------------------------+
273+
| ``severity_emoji`` | Emoji for the severity level |
274+
+-----------------------------+-------------------------------------------------------------+
275+
| ``alert_type`` | "Alert", "K8s Event", or "Notification" |
276+
+-----------------------------+-------------------------------------------------------------+
277+
| ``cluster_name`` | The name of the cluster |
278+
+-----------------------------+-------------------------------------------------------------+
279+
| ``investigate_uri`` | URI for investigation |
280+
+-----------------------------+-------------------------------------------------------------+
281+
| ``resource_text`` | Resource identifier (e.g., "Pod/namespace/name") |
282+
+-----------------------------+-------------------------------------------------------------+
283+
| ``subject_kind`` | The Kubernetes resource kind (e.g., "Pod", "Deployment") |
284+
+-----------------------------+-------------------------------------------------------------+
285+
| ``subject_namespace`` | The Kubernetes namespace |
286+
+-----------------------------+-------------------------------------------------------------+
287+
| ``subject_name`` | The name of the Kubernetes resource |
288+
+-----------------------------+-------------------------------------------------------------+
289+
| ``resource_emoji`` | Emoji for the resource type |
290+
+-----------------------------+-------------------------------------------------------------+
291+
| ``mention`` | Any @mentions extracted from the title |
292+
+-----------------------------+-------------------------------------------------------------+
293+
| ``labels`` | Kubernetes labels on the subject resource (dict) |
294+
+-----------------------------+-------------------------------------------------------------+
295+
| ``annotations`` | Kubernetes annotations on the subject resource (dict) |
296+
+-----------------------------+-------------------------------------------------------------+
297+
| ``fingerprint`` | The unique identifier for the alert |
298+
+-----------------------------+-------------------------------------------------------------+

src/robusta/core/model/runner_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from robusta.core.sinks.rocketchat.rocketchat_sink_params import RocketchatSinkConfigWrapper
1919
from robusta.core.sinks.servicenow.servicenow_sink_params import ServiceNowSinkConfigWrapper
2020
from robusta.core.sinks.slack.slack_sink_params import SlackSinkConfigWrapper
21+
from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewConfigWrapper
2122
from robusta.core.sinks.mail.mail_sink_params import MailSinkConfigWrapper
2223
from robusta.core.sinks.telegram.telegram_sink_params import TelegramSinkConfigWrapper
2324
from robusta.core.sinks.victorops.victorops_sink_params import VictoropsConfigWrapper
@@ -55,6 +56,7 @@ class RunnerConfig(BaseModel):
5556
Union[
5657
RobustaSinkConfigWrapper,
5758
SlackSinkConfigWrapper,
59+
SlackSinkPreviewConfigWrapper,
5860
DataDogSinkConfigWrapper,
5961
KafkaSinkConfigWrapper,
6062
MsTeamsSinkConfigWrapper,

src/robusta/core/sinks/sink_factory.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
from robusta.core.sinks.sink_base import SinkBase
2323
from robusta.core.sinks.sink_config import SinkConfigBase
2424
from robusta.core.sinks.slack import SlackSink, SlackSinkConfigWrapper
25+
from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewConfigWrapper
26+
from robusta.core.sinks.slack.preview.slack_sink_preview import SlackSinkPreview
2527
from robusta.core.sinks.telegram import TelegramSink, TelegramSinkConfigWrapper
2628
from robusta.core.sinks.victorops import VictoropsConfigWrapper, VictoropsSink
2729
from robusta.core.sinks.webex import WebexSink, WebexSinkConfigWrapper
@@ -35,6 +37,7 @@
3537
class SinkFactory:
3638
__sink_config_mapping: Dict[Type[SinkConfigBase], Type[SinkBase]] = {
3739
SlackSinkConfigWrapper: SlackSink,
40+
SlackSinkPreviewConfigWrapper: SlackSinkPreview,
3841
RocketchatSinkConfigWrapper: RocketchatSink,
3942
RobustaSinkConfigWrapper: RobustaSink,
4043
MsTeamsSinkConfigWrapper: MsTeamsSink,
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
1+
from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams, SlackSinkConfigWrapper
12
from robusta.core.sinks.slack.slack_sink import SlackSink
2-
from robusta.core.sinks.slack.slack_sink_params import SlackSinkConfigWrapper, SlackSinkParams
3+
4+
# to prevent circular imports in SlackSender, SlackSinkParams and SlackSinkPreviewParams
5+
__all__ = ["SlackSink", "SlackSinkParams", "SlackSinkConfigWrapper"]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewConfigWrapper, SlackSinkPreviewParams
2+
from robusta.core.sinks.slack.slack_sink import SlackSink
3+
4+
5+
class SlackSinkPreview(SlackSink):
6+
params: SlackSinkPreviewParams
7+
8+
def __init__(self, sink_config: SlackSinkPreviewConfigWrapper, registry):
9+
super().__init__(sink_config, registry, is_preview=True)
10+
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from robusta.core.sinks.sink_base_params import SinkBaseParams
2+
from robusta.core.sinks.sink_config import SinkConfigBase
3+
from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams
4+
from typing import Optional, Dict
5+
from pydantic import validator
6+
7+
8+
class SlackSinkPreviewParams(SlackSinkParams):
9+
#TODO: improve the SlackSinkPreviewParams so the slack_custom_templates can be defined once globally and
10+
# only a template name needs to be passed to each channel in the config
11+
slack_custom_templates: Optional[Dict[str, str]] = None # Template name -> custom template content
12+
hide_buttons: bool = False
13+
hide_enrichments: bool = False
14+
15+
16+
@validator('slack_custom_templates')
17+
def check_one_item(cls, v):
18+
if v is not None and len(v) != 1:
19+
raise ValueError("slack_custom_templates must contain exactly one key-value pair")
20+
return v
21+
22+
def get_custom_template(self) -> Optional[str]:
23+
"""Get the custom template if defined"""
24+
if not self.slack_custom_templates:
25+
return None
26+
27+
return next(iter(self.slack_custom_templates.values()))
28+
29+
30+
class SlackSinkPreviewConfigWrapper(SinkConfigBase):
31+
slack_sink_preview: SlackSinkPreviewParams
32+
33+
def get_params(self) -> SinkBaseParams:
34+
return self.slack_sink_preview

src/robusta/core/sinks/slack/slack_sink.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@
1010
class SlackSink(SinkBase):
1111
params: SlackSinkParams
1212

13-
def __init__(self, sink_config: SlackSinkConfigWrapper, registry):
14-
super().__init__(sink_config.slack_sink, registry)
15-
self.slack_channel = sink_config.slack_sink.slack_channel
16-
self.api_key = sink_config.slack_sink.api_key
13+
def __init__(self, sink_config: SlackSinkConfigWrapper, registry, is_preview=False):
14+
slack_sink_params = sink_config.get_params()
15+
super().__init__(slack_sink_params, registry)
16+
self.slack_channel = slack_sink_params.slack_channel
17+
self.api_key = slack_sink_params.api_key
1718
self.slack_sender = slack_module.SlackSender(
18-
self.api_key, self.account_id, self.cluster_name, self.signing_key, self.slack_channel
19+
self.api_key, self.account_id, self.cluster_name, self.signing_key, self.slack_channel, is_preview
1920
)
2021
self.registry.subscribe("replace_callback_with_string", self)
2122

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{# Default template for Slack message headers #}
2+
{# This creates a JIRA-style header with title and context blocks #}
3+
4+
{# First create the title block with status #}
5+
{
6+
"type": "section",
7+
"text": {
8+
"type": "mrkdwn",
9+
"text": "{{ status_emoji }} *[{{ status_text }}] {% if platform_enabled and include_investigate_link %}<{{ investigate_uri }}|{{ title }}>{% else %}{{ title }}{% endif %}*{% if mention %} {{ mention }}{% endif %}"
10+
}
11+
}
12+
13+
{# Then create the context block with metadata #}
14+
{
15+
"type": "context",
16+
"elements": [
17+
{
18+
"type": "mrkdwn",
19+
"text": ":bell: Type: {{ alert_type }}"
20+
},
21+
{
22+
"type": "mrkdwn",
23+
"text": "{{ severity_emoji }} Severity: {{ severity }}"
24+
},
25+
{
26+
"type": "mrkdwn",
27+
"text": ":globe_with_meridians: Cluster: {{ cluster_name }}"
28+
}
29+
{% if resource_text %}
30+
,{
31+
"type": "mrkdwn",
32+
"text": "{{ resource_emoji }} Resource: {{ resource_text }}"
33+
}
34+
{% endif %}
35+
]
36+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import json
2+
import logging
3+
import os
4+
from typing import Any, Dict, List
5+
6+
from jinja2 import Environment, FileSystemLoader, Template, select_autoescape
7+
8+
# Get the directory where our templates are stored
9+
TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)))
10+
11+
12+
class SlackTemplateLoader:
13+
"""
14+
Loads and renders Jinja2 templates for Slack messages.
15+
"""
16+
17+
def __init__(self):
18+
"""Initialize the template environment."""
19+
self.env = Environment(
20+
loader=FileSystemLoader(TEMPLATE_DIR),
21+
autoescape=select_autoescape(["html", "xml"]),
22+
trim_blocks=True,
23+
lstrip_blocks=True,
24+
)
25+
# Cache for templates
26+
self._templates: Dict[str, Template] = {}
27+
28+
def get_template(self, template_name: str) -> Template:
29+
"""
30+
Get a template by name, loading from file if not already cached.
31+
32+
Args:
33+
template_name: The name of the template file (e.g., "header.j2")
34+
35+
Returns:
36+
A Jinja2 Template object
37+
"""
38+
if template_name not in self._templates:
39+
try:
40+
self._templates[template_name] = self.env.get_template(template_name)
41+
except Exception as e:
42+
logging.error(f"Error loading template {template_name}: {e}")
43+
# Return a simple default template as fallback
44+
return Template("Template loading error")
45+
46+
return self._templates[template_name]
47+
48+
def render_to_blocks(self, template: Template, context: Dict[str, Any]) -> List[Dict[str, Any]]:
49+
"""
50+
Render a Jinja2 Template object using the provided context and parse the result as JSON to get Slack blocks.
51+
Args:
52+
template: A Jinja2 Template object
53+
context: Dictionary of variables to pass to the template
54+
Returns:
55+
List of Slack block objects (dictionaries)
56+
"""
57+
try:
58+
rendered = template.render(**context)
59+
blocks = []
60+
for block_str in rendered.strip().split("\n\n"):
61+
if not block_str.strip():
62+
continue
63+
try:
64+
# Try to parse as JSON, but if it fails, log and skip
65+
block = json.loads(block_str)
66+
blocks.append(block)
67+
except json.JSONDecodeError as e:
68+
logging.exception(f"Error parsing JSON from template output: {e}")
69+
logging.warning(f"Problematic JSON (repr): {repr(block_str)}")
70+
continue # Skip this block and continue
71+
except Exception as e:
72+
logging.error(f"Unexpected error parsing block: {e}")
73+
logging.warning(f"Problematic JSON (repr): {repr(block_str)}")
74+
continue
75+
return blocks
76+
except Exception as e:
77+
logging.error(f"Error rendering template: {e}")
78+
return []
79+
80+
def render_custom_template_to_blocks(self, custom_template: str, context: Dict[str, Any]) -> List[Dict[str, Any]]:
81+
try:
82+
template = Template(custom_template)
83+
return self.render_to_blocks(template, context)
84+
except Exception as e:
85+
logging.error(f"Error rendering custom template: {e}")
86+
return self.render_default_template_to_blocks(context)
87+
88+
def render_default_template_to_blocks(self, context: Dict[str, Any]) -> List[Dict[str, Any]]:
89+
DEFAULT_TEMPLATE_NAME="header.j2"
90+
template = self.get_template(DEFAULT_TEMPLATE_NAME)
91+
return self.render_to_blocks(template, context)
92+
93+
94+
# Singleton instance
95+
template_loader = SlackTemplateLoader()

0 commit comments

Comments
 (0)