[原创]Superset增加钉钉报表推送 Webhook & Reports & Alerts

1,632 阅读2分钟

基于superset+Doris创建的报表,报表体验丝滑,界面看上去舒服,Superset默认只支持Slack和EMail推送报表和报警,不能像ELK那样支持Webhook。通过下面几行简单修改即可实现Webhook功能,将数据推送到指定的Webhook上,利用自己擅长的编程语言(如PHP、Java、Python、Go等)玩出属于你的火花,可以发送到钉钉、企业微信、公众号、短信等,畅想无限可能。

这个是本人的案例,每月初将报表推送到钉钉群里。

微信图片_20240813164604.png

我用的docker容器构建的Superset,版本4.0.2,以下基于Docker容器标准来操作的,别的架构方式大同小异,在superset-worker容器中完成如下操作。

新增文件/app/superset/reports/notifications/webhook.py

# file:/app/superset/reports/notifications/webhook.py
import json
import logging
import textwrap
import requests
import base64
from dataclasses import dataclass
from email.utils import make_msgid, parseaddr
from typing import Any, Optional

from flask_babel import gettext as __

from superset import app
from superset.exceptions import SupersetErrorsException
from superset.reports.models import ReportRecipientType
from superset.reports.notifications.base import BaseNotification
from superset.reports.notifications.exceptions import NotificationError
from superset.utils.core import HeaderDataType, send_email_smtp
from superset.utils.decorators import statsd_gauge

logger = logging.getLogger(__name__)


@dataclass
class WebhookContent:
    body: str
    header_data: Optional[HeaderDataType] = None
    data: Optional[dict[str, Any]] = None
    images: Optional[dict[str, bytes]] = None


class WebhookNotification(BaseNotification):  # pylint: disable=too-few-public-methods
    """
    Calls webhook for a report recipient
    """

    type = ReportRecipientType.WEBHOOK


    @staticmethod
    def _error_template(text: str) -> str:
        return __(
            """
            Error: %(text)s
            """,
            text=text,
        )

    def _get_content(self) -> WebhookContent:
        if self._content.text:
            return WebhookContent(body=self._error_template(self._content.text))
        # Get the domain from the 'From' address ..
        # and make a message id without the < > in the end
        csv_data = None
        images = {}

        if self._content.screenshots:
            images = [
                base64.b64encode(screenshot).decode('ascii')
                for screenshot in self._content.screenshots
            ]

        if self._content.csv:
            csv_data = {__("%(name)s.csv", name=self._content.name): self._content.csv}
        return WebhookContent(
            images=images,
            data=csv_data,
            body=None,
            header_data=self._content.header_data,
        )

    def _get_subject(self) -> str:
        return __(
            "%(prefix)s %(title)s",
            prefix=app.config["EMAIL_REPORTS_SUBJECT_PREFIX"],
            title=self._content.name,
        )

    def _get_to(self) -> str:
        return json.loads(self._recipient.recipient_config_json)["target"]

    @statsd_gauge("reports.webhook.send")
    def send(self) -> None:
       subject = self._get_subject()
       content = self._get_content()
       to = self._get_to()


       headers = {
       }

       payload = {
          'subject': subject,
          'content': {
             #'header_data': content.header_data,
             'body': content.body,
             'data': content.data,
             'images': content.images,
          }
       }

       try:
          response = requests.post(to, headers=headers, data=json.dumps(payload), timeout=30)
          response.raise_for_status()
          logger.info("Report sent to webhook, notification content is %s, url:%s, status scode:%d", content.header_data, to, response.status_code)
       except requests.exceptions.HTTPError as ex:
          raise NotificationError(f"HTTP error occurred: {ex}") from ex
       except Exception as ex:
          raise NotificationError(f"An error occurred: {ex}") from ex

  • 修改/app/superset/views/base.py:
    if conf.get("SLACK_API_TOKEN"):
        frontend_config["ALERT_REPORTS_NOTIFICATION_METHODS"] = [
            ReportRecipientType.EMAIL,
            ReportRecipientType.SLACK,
            ReportRecipientType.WEBHOOK,  # 增加此行
        ]
    else:
        frontend_config["ALERT_REPORTS_NOTIFICATION_METHODS"] = [
            ReportRecipientType.EMAIL,
            ReportRecipientType.WEBHOOK, # 增加此行
        ]
  • 修改/app/superset/reports/models.py:
class ReportRecipientType(StrEnum):
    EMAIL = "Email"
    SLACK = "Slack"
    WEBHOOK = "Webhook" #增加此行
  • 修改/app/superset/reports/notifications/__init__.py
from superset.reports.models import ReportRecipients
from superset.reports.notifications.base import BaseNotification, NotificationContent
from superset.reports.notifications.email import EmailNotification
from superset.reports.notifications.slack import SlackNotification
from superset.reports.notifications.webhook import WebhookNotification #增加此行

  • 完成以上操作后重启服务,此时可以看到通知方式中有了Webhook选项,在接收者中填写API地址(我的这个地址是指向PHP写的接口,最终推送到钉钉群里):

微信图片_20240813164040.png

APi将会收到如下json数据:

   payload = {
      'subject': subject,  //字符串
      'content': {
         'body': content.body,  //字符串
         'data': content.data,  //字符串
         'images': content.images, //数组 [图片base64字符串]
      }
   }


最后附上我的PHP的Webhook逻辑和docker-compose代码,仅供参考,这部分根据自己情况了~!

php代码
class Superset extends Base
{
    public function report(){

        $input = file_get_contents('php://input');
        $data = json_decode($input, true);
        if(empty($data)){
            exit('data empty');
        }
        $markdown = "### ".$data['subject'];
        foreach($data['content']['images'] as $k=>$v){
            $url = $this->upload($v); //将base64图片上传到cdn上,并获取url
            $markdown .= "\r\n";
            $markdown .= "![报表](".$url.")";
        }
        $url = 'https://oapi.dingtalk.com/robot/send?access_token=xxx';
        $payload = [
            'msgtype' => 'markdown',
            'markdown' => [
                'title' => '数据报告',
                'text' => $markdown,
            ]
        ];
        $resp = Services::guzzle()->post($url, ['json' => $payload]);
        Services::logging()->write_log('superset', 'Call DINGDING robot api, response code:'.$resp->getStatusCode().',payload:'.var_export($payload, true).',content:'.$resp->getBody()->getContents());
    }
    private function upload($blob){
        ....
    }
}
docker-compose.yml
x-superset-volumes:
  &superset-volumes # /app/pythonpath_docker will be appended to the PYTHONPATH in the final container
  - ./docker:/app/docker
  - superset_home:/app/superset_home

x-common-build: &common-build apache/superset:4.0.2

services:

  superset:
    env_file:
      - path: docker/.env # default
        required: true
    image: *common-build
    container_name: superset_app
    command: ["/app/docker/docker-bootstrap.sh", "app-gunicorn"]
    user: "root"
    restart: unless-stopped
    networks:
      - superset
    ports:
      - 8081:8088
    volumes: *superset-volumes

  superset-init:
    container_name: superset_init
    image: *common-build
    command: ["/app/docker/docker-init.sh"]
    env_file:
      - path: docker/.env # default
        required: true
    user: "root"
    volumes: *superset-volumes
    networks:
      - superset
    healthcheck:
      disable: true

  superset-worker:
    image: *common-build
    container_name: superset_worker
    command: ["/app/docker/docker-bootstrap.sh", "worker"]
    env_file:
      - path: docker/.env # default
        required: true
    restart: unless-stopped
    user: "root"
    volumes: *superset-volumes
    networks:
      - superset
    healthcheck:
      test:
        [
          "CMD-SHELL",
          "celery -A superset.tasks.celery_app:app inspect ping -d celery@$$HOSTNAME",
        ]

  superset-worker-beat:
    image: *common-build
    container_name: superset_worker_beat
    command: ["/app/docker/docker-bootstrap.sh", "beat"]
    env_file:
      - path: docker/.env # default
        required: true
    restart: unless-stopped
    user: "root"
    volumes: *superset-volumes
    networks:
      - superset
    healthcheck:
      disable: true

volumes:
  superset_home:
    external: false

networks:
  superset:
    ipam:
      config:
      - subnet: 172.19.1.0/24