Skip to content
32 changes: 32 additions & 0 deletions docs/configuration/holmesgpt/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,38 @@ Reading the Robusta UI Token from a secret in HolmesGPT

Run a :ref:`Helm Upgrade <Simple 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
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
Binary file added docs/images/Enabling_AI_in_slack.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 30 additions & 0 deletions src/robusta/core/sinks/robusta/robusta_sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion src/robusta/core/sinks/slack/slack_sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
59 changes: 27 additions & 32 deletions src/robusta/integrations/slack/sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Comment thread
Avi-Robusta marked this conversation as resolved.
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 <https://platform.robusta.dev/create-account|Robusta SaaS> and tagging @holmes._")
elif platform_enabled and not slackbot_enabled:
return MarkdownBlock("_Ask AI questions about this alert, by adding @holmes to your <https://docs.robusta.dev/master/configuration/holmesgpt/index.html#enable-holmes-in-slack-in-the-platform|Slack>._")
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,
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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())

Expand Down
2 changes: 2 additions & 0 deletions tests/manual_tests/test_slack_integration_manual.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ def main():
cluster_name="test-cluster",
signing_key="test-signing-key",
slack_channel=SLACK_CHANNEL,
registry=None,
is_preview=False
)

Expand All @@ -452,6 +453,7 @@ def main():
cluster_name="test-cluster",
signing_key="test-signing-key",
slack_channel=SLACK_CHANNEL,
registry=None,
is_preview=True
)

Expand Down
4 changes: 2 additions & 2 deletions tests/test_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 6 additions & 6 deletions tests/test_slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)])
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/test_slack_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading