Heroku to Slack with AWS Lambda

When my production app processes change state on Heroku, I want to be notified in Slack:

Screenshot of Slack notification

Other examples:

17:38:52 clock.1 `bundle exec ruby schedule/clock.rb` up
17:39:03 web.1 `bundle exec puma -p $PORT -C ./config/puma.rb` up
17:39:05 web.2 `bundle exec puma -p $PORT -C ./config/puma.rb` up

Pager-notifying events:

17:38:52 queuenote.1 `bundle exec ruby queue/note.rb` crashed

Heroku has webhooks for these events but their payloads aren't in the format needed for Slack incoming webhooks.

AWS Lambda is the perfect glue to transform the Heroku webhook's JSON payload into a useful JSON payload for Slack's incoming webhook.

Slack config

Create an incoming webhook. Copy the URL.

Lambda config

Create a Lambda function. AWS' supported runtimes include Node, Python, Ruby, and Go. You can alternatively implement a custom runtime. Here's an example in Ruby:

require "json"
require "net/http"
require "time"
require "uri"

def lambda_handler(event:, context:)
  json = JSON.parse(event["body"])
  puts json

  if !["dyno", "collaborator"].include?(json["resource"])
    return {
      statusCode: 400,
      headers: {"Content-Type": "application/json"},
      body: "webhook event type not supported"
    }
  end

  localtime = Time
    .parse(json.dig("created_at"))
    .getlocal("-08:00")
    .strftime("%H:%M:%S")

  if json.dig("resource") == "dyno"
    # https://devcenter.heroku.com/articles/webhook-events#api-dyno
    name = json.dig("data", "name") || ""
    state = json.dig("data", "state") || ""

    ignored_states = ["starting", "down"].include?(state)
    term_one_off = state == "crashed" && ["scheduler", "run"].any? { |p| name.include?(p) }

    if ignored_states || term_one_off || name.include?("release")
      return {
        statusCode: 200,
        headers: {"Content-Type": "application/json"},
        body: "ok"
      }
    end

    text = [
      localtime,
      name,
      "`#{json.dig("data", "command")}`", # backticks for code formatting in Slack
      state
    ].compact.join(" ")
  end

  if json.dig("resource") == "collaborator"
    # https://devcenter.heroku.com/articles/webhook-events#api-collaborator

    text = [
      localtime,
      json.dig("actor", "email"),
      "#{json.dig("action")}'d collaborator",
      json.dig("data", "user", "email")
    ].compact.join(" ")
  end

  uri = URI.parse(ENV.fetch("SLACK_URL"))
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true

  req = Net::HTTP::Post.new(uri.request_uri)
  req["Content-Type"] = "application/json"
  req.body = {text: text}.to_json

  res = http.request(req)

  {
    statusCode: 200,
    headers: {"Content-Type": "application/json"},
    body: res.body
  }
end

Paste the Slack incoming webhook URL as an environment variable, which is encrypted at rest.

Create an API Gateway to make the Lambda function accessible in the Heroku web UI.

Heroku config

Go to:

https://dashboard.heroku.com/apps/YOUR-APP-NAME/webhooks

Create a webhook with event type "dyno". Paste the API Gateway URL as the Payload URL.

Modify to taste

Edit and save the code in Lambda's web-based text editor. Trigger a webhook to test the function. View the auto-created CloudWatch logs for each function call.