Skip to content

Commit

Permalink
Google Chat webhook sink
Browse files Browse the repository at this point in the history
  • Loading branch information
Robert Szefler committed Jan 12, 2024
1 parent 593368c commit 9a581e1
Show file tree
Hide file tree
Showing 8 changed files with 209 additions and 0 deletions.
29 changes: 29 additions & 0 deletions docs/configuration/sinks/google_chat.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
Google Chat
#################

Robusta can report issues and events in your Kubernetes cluster by sending
messages via the Google Chat.

Setting up the Google Chat integration
------------------------------------------------

All you need to set up Google Chat sink for Robusta is to enable a webhook
for a certain Google Chat Space. This essentially means that an administrator
of the chat has to extract a special URL for this Chat Space that enables
the integration. You can find out more about webhook URLs in the Google
documentation, for example <here|https://developers.google.com/chat/how-tos/webhooks>

Configuring the Google Chat sink in Robusta
------------------------------------------------

.. admonition:: Add this to your generated_values.yaml

.. code-block:: yaml
sinksConfig:
- google_chat_sink:
name: gchat_sink
webhook_url: https://chat.googleapis.com/v1/spaces/space-id/messages?key=xyz&token=pqr
Then do a :ref:`Helm Upgrade <Simple Upgrade>`.
2 changes: 2 additions & 0 deletions src/robusta/core/model/runner_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from robusta.core.sinks.datadog.datadog_sink_params import DataDogSinkConfigWrapper
from robusta.core.sinks.discord.discord_sink_params import DiscordSinkConfigWrapper
from robusta.core.sinks.file.file_sink_params import FileSinkConfigWrapper
from robusta.core.sinks.google_chat.google_chat_params import GoogleChatSinkConfigWrapper
from robusta.core.sinks.jira.jira_sink_params import JiraSinkConfigWrapper
from robusta.core.sinks.kafka.kafka_sink_params import KafkaSinkConfigWrapper
from robusta.core.sinks.mattermost.mattermost_sink_params import MattermostSinkConfigWrapper
Expand Down Expand Up @@ -57,6 +58,7 @@ class RunnerConfig(BaseModel):
JiraSinkConfigWrapper,
FileSinkConfigWrapper,
MailSinkConfigWrapper,
GoogleChatSinkConfigWrapper,
]
]
]
Expand Down
Empty file.
19 changes: 19 additions & 0 deletions src/robusta/core/sinks/google_chat/google_chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from robusta.core.reporting.base import Finding
from robusta.core.sinks.sink_base import SinkBase
from robusta.core.sinks.google_chat.google_chat_params import GoogleChatSinkConfigWrapper
from robusta.integrations.google_chat.sender import GoogleChatSender


class GoogleChatSink(SinkBase):
def __init__(self, sink_config: GoogleChatSinkConfigWrapper, registry):
sink_params = sink_config.get_params()
super().__init__(sink_params, registry)
self.sender = GoogleChatSender(
sink_params,
self.signing_key,
self.account_id,
self.cluster_name,
)

def write_finding(self, finding: Finding, platform_enabled: bool):
self.sender.send_finding(finding, platform_enabled)
15 changes: 15 additions & 0 deletions src/robusta/core/sinks/google_chat/google_chat_params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from pydantic import validator

from robusta.core.sinks.sink_base_params import SinkBaseParams
from robusta.core.sinks.sink_config import SinkConfigBase


class GoogleChatSinkParams(SinkBaseParams):
webhook_url: str


class GoogleChatSinkConfigWrapper(SinkConfigBase):
google_chat_sink: GoogleChatSinkParams

def get_params(self) -> GoogleChatSinkParams:
return self.google_chat_sink
3 changes: 3 additions & 0 deletions src/robusta/core/sinks/sink_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from robusta.core.sinks.discord import DiscordSink, DiscordSinkConfigWrapper
from robusta.core.sinks.file.file_sink import FileSink
from robusta.core.sinks.file.file_sink_params import FileSinkConfigWrapper
from robusta.core.sinks.google_chat.google_chat_params import GoogleChatSinkConfigWrapper
from robusta.core.sinks.google_chat.google_chat import GoogleChatSink
from robusta.core.sinks.jira import JiraSink, JiraSinkConfigWrapper
from robusta.core.sinks.kafka import KafkaSink, KafkaSinkConfigWrapper
from robusta.core.sinks.mail.mail_sink import MailSink
Expand Down Expand Up @@ -45,6 +47,7 @@ class SinkFactory:
JiraSinkConfigWrapper: JiraSink,
FileSinkConfigWrapper: FileSink,
MailSinkConfigWrapper: MailSink,
GoogleChatSinkConfigWrapper: GoogleChatSink,
}

@classmethod
Expand Down
Empty file.
141 changes: 141 additions & 0 deletions src/robusta/integrations/google_chat/sender.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import logging
from typing import Dict, List

from robusta.core.reporting.base import BaseBlock, Emojis, Finding, FindingStatus
from robusta.core.reporting.blocks import (
LinksBlock,
LinkProp,
MarkdownBlock, FileBlock, HeaderBlock, TableBlock, ListBlock,
)
from robusta.core.reporting.consts import FindingSource
from robusta.core.sinks.google_chat.google_chat_params import GoogleChatSinkParams

import requests


class GoogleChatSender:
def __init__(self, params: GoogleChatSinkParams, signing_key, account_id, cluster_name):
self.params = params
self.signing_key = signing_key
self.account_id = account_id
self.cluster_name = cluster_name

def send_finding(self, finding: Finding, platform_enabled: bool):
blocks: List[BaseBlock] = []

status: FindingStatus = (
FindingStatus.RESOLVED if finding.title.startswith("[RESOLVED]") else FindingStatus.FIRING
)
blocks.append(self.__create_finding_header(finding, status))

if platform_enabled:
blocks.append(self.__create_links(finding))

blocks.append(MarkdownBlock(text=f"*Source:* `{self.cluster_name}`\n"))
if finding.description:
if finding.source == FindingSource.PROMETHEUS:
blocks.append(MarkdownBlock(f"{Emojis.Alert.value} *Alert:* {finding.description}"))
elif finding.source == FindingSource.KUBERNETES_API_SERVER:
blocks.append(
MarkdownBlock(f"{Emojis.K8Notification.value} *K8s event detected:* {finding.description}")
)
else:
blocks.append(MarkdownBlock(f"{Emojis.K8Notification.value} *Notification:* {finding.description}"))

for enrichment in finding.enrichments:
items = [
self.__block_to_markdown_text(block)
for block in enrichment.blocks
if not isinstance(block, FileBlock)
]
if items:
blocks.extend(items)

data = self.__blocks_to_data(blocks)
self.__send_to_api(data)

def __blocks_to_data(self, blocks: List[BaseBlock]) -> Dict:
text = ""
for block in blocks:
appendix = self.__block_to_markdown_text(block)
if appendix is not None:
text += appendix
return {"text": text}

def __block_to_markdown_text(self, block: BaseBlock) -> str:
if isinstance(block, MarkdownBlock):
return block.text
elif isinstance(block, FileBlock):
# We ignore this case as we are not able to send attachments
# using the Google Chat webhook API.
return None
elif isinstance(block, HeaderBlock):
return MarkdownBlock(block.text).text
elif isinstance(block, LinksBlock):
return self.__format_links(block.links) + "\n\n"
elif isinstance(block, TableBlock):
if len(block.headers) == 2:
return (
f"\n\n{block.table_name}\n"
+ "\n".join(f" - {key} `{value}`" for key, value in block.rows)
+ "\n"
)
else:
# A table wider than 2 columns. Slack sink for example converts this to file
# attachments. We ignore this case as we are not able to send attachments
# using the Google Chat webhook API.
return None
elif isinstance(block, ListBlock):
if ''.join(block.items) == '': return None
return (
"\n"
+ "\n".join(f" - {self.__block_to_markdown_text(item)}" for item in block.items)
+ "\n"
)
elif isinstance(block, str):
return block if str else None
else:
logging.warning(f"cannot convert block of type {type(block)} to Google Chat format block")
return None

def __format_links(self, links: List[LinkProp]):
return "\n".join(f"<{link.url}|*{link.text}*>" for link in links)

def __send_to_api(self, data: Dict):
try:
resp = requests.post(self.params.webhook_url, json=data)
# logging.warning(f"{resp.headers} {resp.content}")
except Exception:
logging.exception(f"Webhook request error\n headers: \n{resp.headers}")

def __create_finding_header(self, finding: Finding, status: FindingStatus) -> MarkdownBlock:
title = finding.title.removeprefix("[RESOLVED] ")
sev = finding.severity
status_name: str = "Prometheus Alert Firing" if status == FindingStatus.FIRING else "Prometheus resolved"
status_str: str = f"{status.to_emoji()} `{status_name}`"
return MarkdownBlock(
f"{status_str} {sev.to_emoji()} `{sev.name.lower()}`\n"
f"<{finding.get_investigate_uri(self.account_id, self.cluster_name)}|*{title}*>\n\n"
)

def __create_links(self, finding: Finding):
links: List[LinkProp] = []
links.append(
LinkProp(
text="Investigate 🔎",
url=finding.get_investigate_uri(self.account_id, self.cluster_name),
)
)

if finding.add_silence_url:
links.append(
LinkProp(
text="Configure Silences 🔕",
url=finding.get_prometheus_silence_url(self.account_id, self.cluster_name),
)
)

for video_link in finding.video_links:
links.append(LinkProp(text=f"{video_link.name} 🎬", url=video_link.url))

return LinksBlock(links=links)

0 comments on commit 9a581e1

Please sign in to comment.