diff --git a/docs/configuration/holmesgpt/index.rst b/docs/configuration/holmesgpt/index.rst index 337245d92..722466c83 100644 --- a/docs/configuration/holmesgpt/index.rst +++ b/docs/configuration/holmesgpt/index.rst @@ -311,6 +311,38 @@ Reading the Robusta UI Token from a secret in HolmesGPT Run a :ref:`Helm Upgrade ` to apply the configuration. +Enable Holmes in Slack in the Platform +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +1. **Go to** https://platform.robusta.dev/ + +2. **Navigate to:** + **Settings** → **AI Assistant** + +.. image:: /images/Enabling_AI_in_slack.png + :width: 1000px + +3. **Enable Holmes** using the toggle. + +4. **Click** **Connect Slack Workspace** to authorize Holmes in your Slack workspace. + +5. **Use Holmes in Slack** + + In any Slack channel or thread, tag Holmes using `@holmes` like:: + + @holmes can you look into this + + Or ask natural language questions about a specific cluster. Examples:: + +.. code-block:: bash + @holmes what apps are crashing in my `prod-cluster` + @holmes show me the CPU usage for the frontend deployment in `staging-cluster` + @holmes why is my alert firing on `eu-prod-atc-eks`? + @holmes investigate high memory usage in `dev-cluster` + + Holmes will respond in the thread with insights and troubleshooting steps based on the specified cluster. + + Test Holmes Integration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/images/Enabling_AI_in_slack.png b/docs/images/Enabling_AI_in_slack.png new file mode 100644 index 000000000..d9bd378bc Binary files /dev/null and b/docs/images/Enabling_AI_in_slack.png differ diff --git a/src/robusta/core/sinks/robusta/robusta_sink.py b/src/robusta/core/sinks/robusta/robusta_sink.py index d0fde3d4c..2f7f8033b 100644 --- a/src/robusta/core/sinks/robusta/robusta_sink.py +++ b/src/robusta/core/sinks/robusta/robusta_sink.py @@ -42,7 +42,14 @@ from robusta.integrations.receiver import ActionRequestReceiver from robusta.runner.web_api import WebApi from robusta.utils.stack_tracer import StackTracer +from robusta.core.model.env_vars import ROBUSTA_API_ENDPOINT +from cachetools import TTLCache +RUNNER_GET_HOLMES_SLACKBOT_INFO = f"{ROBUSTA_API_ENDPOINT}/api/holmes/integrations/slack/runner" +HOLMES_SLACKBOT_CACHE_TTL = int(os.getenv("HOLMES_SLACKBOT_CACHE_TTL", 15 * 60)) + +# Define the cache with a single slot and the configured TTL +_holmes_slackbot_cache = TTLCache(maxsize=1, ttl=HOLMES_SLACKBOT_CACHE_TTL) class RobustaSink(SinkBase, EventHandler): services_publish_lock = threading.Lock() @@ -703,3 +710,26 @@ def __update_job(self, new_job: Job, operation: K8sOperationType): self.__safe_delete_job(job_key) self.__discovery_metrics.on_jobs_updated(1) return + + def is_holmes_slackbot_connected(self) -> bool: + if 'status' in _holmes_slackbot_cache: + return _holmes_slackbot_cache['status'] + session_token = self.dal.get_session_token() + try: + message_json = { + "session_token": session_token, + "account_id": self.account_id, + } + response = requests.post( + RUNNER_GET_HOLMES_SLACKBOT_INFO, + json=message_json, + headers={"Content-Type": "application/json"} + ) + response.raise_for_status() + is_connected = bool(response.json().get("integrations")) + _holmes_slackbot_cache['status'] = is_connected + return is_connected + except Exception as e: + logging.warning(f"Failed to get holmes slackbot info {e}") + _holmes_slackbot_cache['status'] = False + return False \ No newline at end of file diff --git a/src/robusta/core/sinks/slack/slack_sink.py b/src/robusta/core/sinks/slack/slack_sink.py index ceca00508..9c8bc4d27 100644 --- a/src/robusta/core/sinks/slack/slack_sink.py +++ b/src/robusta/core/sinks/slack/slack_sink.py @@ -16,7 +16,7 @@ def __init__(self, sink_config: SlackSinkConfigWrapper, registry, is_preview=Fal self.slack_channel = slack_sink_params.slack_channel self.api_key = slack_sink_params.api_key self.slack_sender = slack_module.SlackSender( - self.api_key, self.account_id, self.cluster_name, self.signing_key, self.slack_channel, is_preview + self.api_key, self.account_id, self.cluster_name, self.signing_key, self.slack_channel, registry, is_preview ) self.registry.subscribe("replace_callback_with_string", self) diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 543f32a48..5316a943b 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -48,7 +48,6 @@ from robusta.core.sinks.sink_base import KeyT from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewParams - from robusta.core.sinks.transformer import Transformer ACTION_TRIGGER_PLAYBOOK = "trigger_playbook" @@ -62,7 +61,7 @@ class SlackSender: verified_api_tokens: Set[str] = set() channel_name_to_id = {} - def __init__(self, slack_token: str, account_id: str, cluster_name: str, signing_key: str, slack_channel: str, is_preview: bool = False): + def __init__(self, slack_token: str, account_id: str, cluster_name: str, signing_key: str, slack_channel: str, registry, is_preview: bool = False): """ Connect to Slack and verify that the Slack token is valid. Return True on success, False on failure @@ -80,6 +79,7 @@ def __init__(self, slack_token: str, account_id: str, cluster_name: str, signing timeout=SLACK_REQUEST_TIMEOUT, retry_handlers=all_builtin_retry_handlers(), ) + self.registry = registry self.signing_key = signing_key self.account_id = account_id self.cluster_name = cluster_name @@ -394,33 +394,6 @@ def __limit_labels_size(self, labels: dict, max_size: int = 1000) -> dict: return limited_labels - def __create_holmes_callback(self, finding: Finding) -> CallbackBlock: - resource = ResourceInfo( - name=finding.subject.name if finding.subject.name else "", - namespace=finding.subject.namespace, - kind=finding.subject.subject_type.value if finding.subject.subject_type.value else "", - node=finding.subject.node, - container=finding.subject.container, - ) - - context: Dict[str, Any] = { - "robusta_issue_id": str(finding.id), - "issue_type": finding.aggregation_key, - "source": finding.source.name, - "labels": self.__limit_labels_size(labels=finding.subject.labels), - } - - return CallbackBlock( - { - "Ask HolmesGPT": CallbackChoice( - action=ask_holmes, - action_params=AIInvestigateParams( - resource=resource, investigation_type="issue", ask="Why is this alert firing?", context=context - ), - ) - } - ) - @staticmethod def extract_mentions(title) -> (str, str): mentions = MENTION_PATTERN.findall(title) @@ -667,6 +640,16 @@ def send_holmes_analysis( except Exception: logging.exception(f"error sending message to slack. {title}") + def get_holmes_block(self, platform_enabled: bool, slackbot_enabled) -> Optional[MarkdownBlock]: + if not platform_enabled and not slackbot_enabled: + return MarkdownBlock("_Ask AI questions about this alert, by connecting and tagging @holmes._") + elif platform_enabled and not slackbot_enabled: + return MarkdownBlock("_Ask AI questions about this alert, by adding @holmes to your ._") + elif platform_enabled and slackbot_enabled: + return MarkdownBlock("_Ask AI questions about this alert, by tagging @holmes in a threaded reply_") + return None + + def send_finding_to_slack( self, finding: Finding, @@ -691,6 +674,15 @@ def send_finding_to_slack( thread_ts=thread_ts ) + def __is_holmes_slackbot_enabled(self) -> bool: + robusta_sinks = self.registry.get_sinks().get_robusta_sinks() if self.registry else None + if not robusta_sinks: + logging.debug("No robusta sinks found, holmes not connected to slackbot") + return False + + robusta_sink = robusta_sinks[0] + return robusta_sink.is_holmes_slackbot_connected() + def __send_finding_to_slack( self, finding: Finding, @@ -725,9 +717,6 @@ def __send_finding_to_slack( ) blocks.append(links_block) - if HOLMES_ENABLED and HOLMES_ASK_SLACK_BUTTON_ENABLED: - blocks.append(self.__create_holmes_callback(finding)) - blocks.append(MarkdownBlock(text=f"*Source:* `{self.cluster_name}`")) if finding.description: if finding.source == FindingSource.PROMETHEUS: @@ -753,6 +742,12 @@ def __send_finding_to_slack( blocks.append(DividerBlock()) + is_holmes_slackbot_enabled = self.__is_holmes_slackbot_enabled() + holmes_block = self.get_holmes_block(platform_enabled, is_holmes_slackbot_enabled) + if holmes_block: + blocks.append(holmes_block) + + if len(attachment_blocks): attachment_blocks.append(DividerBlock()) diff --git a/tests/manual_tests/test_slack_integration_manual.py b/tests/manual_tests/test_slack_integration_manual.py index d9751338c..bc64ac9a7 100644 --- a/tests/manual_tests/test_slack_integration_manual.py +++ b/tests/manual_tests/test_slack_integration_manual.py @@ -442,6 +442,7 @@ def main(): cluster_name="test-cluster", signing_key="test-signing-key", slack_channel=SLACK_CHANNEL, + registry=None, is_preview=False ) @@ -452,6 +453,7 @@ def main(): cluster_name="test-cluster", signing_key="test-signing-key", slack_channel=SLACK_CHANNEL, + registry=None, is_preview=True ) diff --git a/tests/test_blocks.py b/tests/test_blocks.py index 2cf90a20e..86fdc4517 100644 --- a/tests/test_blocks.py +++ b/tests/test_blocks.py @@ -31,7 +31,7 @@ def test_send_to_slack(slack_channel: SlackChannel): slack_sender = SlackSender( - CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, registry=None ) msg = "Test123" finding = Finding(title=msg, aggregation_key=msg) @@ -127,7 +127,7 @@ def test_callback(event: ExecutionBaseEvent): def test_all_block_types(slack_channel: SlackChannel): slack_sender = SlackSender( - CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name,registry=None ) slack_params = SlackSinkParams(name="test_slack", slack_channel=slack_channel.channel_name, api_key="") finding = create_finding_with_all_blocks() diff --git a/tests/test_slack.py b/tests/test_slack.py index 0e99a18c3..9c0818e73 100644 --- a/tests/test_slack.py +++ b/tests/test_slack.py @@ -17,7 +17,7 @@ def test_send_to_slack(slack_channel: SlackChannel): slack_sender = SlackSender( - CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, registry=None ) msg = "Test123" finding = Finding(title=msg, aggregation_key=msg) @@ -29,7 +29,7 @@ def test_send_to_slack(slack_channel: SlackChannel): def test_long_slack_messages(slack_channel: SlackChannel): slack_sender = SlackSender( - CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, registry=None ) finding = Finding(title="A" * 151, aggregation_key="A" * 151) finding.add_enrichment([MarkdownBlock("H" * 3001)]) @@ -39,7 +39,7 @@ def test_long_slack_messages(slack_channel: SlackChannel): def test_long_table_columns(slack_channel: SlackChannel): slack_sender = SlackSender( - CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, registry=None ) finding = Finding(title="Testing table blocks", aggregation_key="TestingTableBlocks") finding.add_enrichment( @@ -59,7 +59,7 @@ def test_long_table_columns(slack_channel: SlackChannel): def test_send_file_spooled_tempfile_fails(slack_channel: SlackChannel): slack_sender = SlackSender( - CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, registry=None ) # Test with a text file @@ -82,7 +82,7 @@ def test_send_file_spooled_tempfile_fails(slack_channel: SlackChannel): def test_send_file_named_tempfile_fails(slack_channel: SlackChannel): slack_sender = SlackSender( - CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, registry=None ) finding = Finding(title=TEST_FINDING_TITLE, aggregation_key="TestTextFileUpload") @@ -102,7 +102,7 @@ def test_send_file_named_tempfile_fails(slack_channel: SlackChannel): def test_temporary_file_creation_failure(slack_channel: SlackChannel): slack_sender = SlackSender( - CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, registry=None ) # Test with a text file diff --git a/tests/test_slack_preview.py b/tests/test_slack_preview.py index 9a2ce3da1..9245710d5 100755 --- a/tests/test_slack_preview.py +++ b/tests/test_slack_preview.py @@ -31,7 +31,7 @@ def extract_text_from_blocks(message): def test_slack_preview_default_template(slack_channel: SlackChannel): slack_sender = SlackSender( - CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, is_preview=True + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, registry=None, is_preview=True ) # Create a subject for the finding @@ -83,7 +83,7 @@ def test_slack_preview_default_template(slack_channel: SlackChannel): def test_slack_preview_custom_template(slack_channel: SlackChannel): slack_sender = SlackSender( - CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, is_preview=True + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, registry=None, is_preview=True ) subject = FindingSubject(