Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Google Chat webhook sink #1245

Merged
merged 1 commit into from
Jan 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 SecretStr

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


class GoogleChatSinkParams(SinkBaseParams):
webhook_url: SecretStr


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.
143 changes: 143 additions & 0 deletions src/robusta/integrations/google_chat/sender.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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\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
# Eliminate double endlines that might have appeared due to e.g. the way we are
# converting MarkdownBlocks into text.
text = text.replace("\n\n", "\n")
return {"text": text}

def __block_to_markdown_text(self, block: BaseBlock) -> str:
if isinstance(block, MarkdownBlock):
return block.text + "\n"
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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use tabulate to convert the table to string?
see to_table_string in blocks.py

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per tabulate documentation, this would result in:

>>> print(tabulate(table, headers, tablefmt="presto"))
 item   |   qty
--------+-------
 spam   |    42
 eggs   |   451
 bacon  |     0

What I wanted to achieve in this case was more like a bullet list, to make this as similar to Slack/mail formatting as possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional note: tables wider than 2 columns are rendered using to_table_string

# This is rendered as a bullet list to make it more consistent with the
# way it's presented in
return (
f"\n\n{block.table_name}\n"
+ "\n".join(f" • {key} `{value}`" for key, value in block.rows)
+ "\n\n"
)
else:
return "```\n" + block.to_table_string(table_max_width=120) + "```"
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\n"
)
elif isinstance(block, str):
return block if str else None
else:
if block is not None: # None means nothing to render and is acceptable here
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}*>\n" for link in links)

def __send_to_api(self, data: Dict):
try:
resp = requests.post(self.params.webhook_url.get_secret_value(), json=data)
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)
Loading