Improving responsiveness of hubot-grafana

We 🧡 the combination of Grafana, Hubot & Slack. We use it all the time to visualize dashboards in Slack. But the interaction with certain dashboards might not be as fast as one expect from a chat bot, so let's see what we can do to improve the hubot-grafana interaction.

The end result we're going for is:

UpdatableMessage to the rescue

We've been working with an UpdatableMessage for a while now. It makes it easier to post a message on Slack and update it later on. This lets the user know that the request has been received and we're working on it. The class has since 2018 evolved and is still used throughout the bot-zero project.

Now, let's create some receive middleware that use the UpdatableMessage to inject a loading message:

import { createUpdatableMessage } from "../common/UpdatableMessage"

function rewriteGrafanaCommands(robot: Hubot.Robot) {
  if (!robot) throw "Argument 'robot' is empty."

  robot.receiveMiddleware(async context => {
    const listeners = (<any>robot).listeners
    const listener = listeners.find(l => l.regex.toString().includes("(?:dash|dashboard|db)"))
    if (!listener) {
      return true
    }

    const text = context.response.message.text
    if (listener.regex.test(text)) {
      let msg = createUpdatableMessage(context.response)
      await msg.send({ text: `:loading: Getting dashboard...` })

      // attach for later use
      ;(<any>context.response.message).msg = msg
    }

    return true
  })
}

module.exports = (robot: Hubot.Robot) => rewriteGrafanaCommands(robot)

Fixing responses

So I have some issues with the hubot-grafana response:

  1. The dashboard links contain relative times like now and now-8h. This is fine when the dashboard is clicked now, but if I read an older message, I would like those relative times to be replaced by the historic times.
  2. The &fullscreen gives are redirect tot localhost:3000. No clue why.
  3. The panelId is not enough, my installation needs a viewPanel to show the right panel.

Fixing relative expressions

Let's use Luxon to process those relative dates and substitute them for the "real" dates:

import { DateTime } from "luxon"

export function replaceTimeExpressions(str: string) {
  const timeStringExpression = /^(now)(\+?)(\-?\d+)([smhdMy])$/

  const unitMapping = {
    s: "seconds",
    m: "minutes",
    h: "hours",
    d: "days",
    M: "months",
    y: "years",
  }

  function parseTimeExpression(expression: string): string {
    if (expression === "now") {
      return new Date().getTime().toString()
    }

    if (!timeStringExpression.test(expression)) {
      return expression
    }

    const match = timeStringExpression.exec(expression)
    const amount = parseInt(match[3])
    const unit = unitMapping[match[4]]

    if (!unit) {
      return expression
    }

    let duration = {}
    duration[unit] = amount

    return DateTime.local().plus(duration).toMillis().toString()
  }

  return str.replace(/now(\-\d+\w)?/g, function (match) {
    return parseTimeExpression(match)
  })
}

Custom Responder to the rescue

In version 6 of the Hubot Grafana package, we've introduced a custom responder object. You can use this feature to override the way the package writes the Slack message. Let's create a custom dashboard responder that works with the UpdatableMessage object that is attached to our context.

import { replaceTimeExpressions } from "../common/grafana/replaceTimeExpressions"
import { setResponder } from "hubot-grafana/src/adapters/Adapter"
import { Responder } from "hubot-grafana/src/adapters/Responder"

class WehkampDashboardResponder extends Responder {
  send(res: Hubot.Response, title: string, imageUrl: string, dashboardLink: string) {
    const useThreads = process.env.HUBOT_GRAFANA_USE_THREADS || false

    imageUrl = this.fixUrl(imageUrl)
    dashboardLink = this.fixUrl(dashboardLink)

    const message = <any>res.message

    let msg: any = {
      attachments: [
        {
          fallback: `${title}: ${imageUrl} - ${dashboardLink}`,
          title,
          title_link: dashboardLink,
          image_url: imageUrl,
        },
      ],
      unfurl_links: false,
      thread_ts: useThreads ? message.rawMessage.ts : null,
    }

    if (!message.msg || message.msg.used == 1) {
      res.send(msg)
      return
    }

    message.msg.used = 1
    message.msg.send(msg)
  }

  fixUrl(url: string) {
    url = url.replace("&fullscreen", "")
    url = replaceTimeExpressions(url)
    url = url.replace(/panelId=(\d+)/, "panelId=$1&viewPanel=$1")
    return url
  }
}

module.exports = (robot: Hubot.Robot) => {
  const responder = new WehkampDashboardResponder()
  setResponder(responder)
}

Note: we may only use the UpdatableMessage once, otherwise we'll update the dashboard that was previously send. res.send will suffice for the other dashboards.

Final thoughts

Sending Grafana dashboards is fun using the Grafana Hubot package. Hubot + Grafana = open source for the win! 🥳

Changelog

  • 2024-04-29: simplify code for replaceTimeExpressions
  • 2024-04-29: the Hubot Grafana package now supports custom responders. I've implemented the changes in a WehkampDashboardResponder class.
  • 2024-01-22: sending error messages as well.
  • 2024-01-18: implemented the latest Hubot middleware signatures.
  • 2024-01-18: fixed a bug where multiple responses were overwriting each other.
  • 2023-08-22: initial article
expand_less