Post a weekly number to Slack

A small Go program that reads last week's revenue from Halo, compares it with the week before, and posts it to a Slack incoming webhook on a schedule.

When you're done, your channel gets a message every Monday morning:

Revenue last week: 431971, up 4.6% on the week before.

It's one Go file with no dependencies, and runs from cron or any scheduler.

Before you start

  1. Make a key at the keys page with only data.query, named something like "Slack digest".

  2. Find your dataset and fields with the Quickstart. This uses order_date and revenue.

  3. Make a Slack incoming webhook. In your Slack app's settings, turn on incoming webhooks, and add one for the channel. Treat its address like a password, since anyone who has it can post to the channel.

read -rs HALO_API_KEY && export HALO_API_KEY
read -rs SLACK_WEBHOOK_URL && export SLACK_WEBHOOK_URL
export HALO_DATASET_ID="0195f3a2-…"

The query

last_n_weeks covers whole weeks before this one, so with n: 2 and a week grain you get at most two rows, and dates run forward. Weeks follow your organization's week start.

{
  "dimensions": [{ "field": "order_date", "grain": "week" }],
  "measures": [{ "field": "revenue" }],
  "filters": [
    {
      "field": "order_date",
      "op": "date_range",
      "range": { "kind": "relative", "preset": "last_n_weeks", "n": 2 }
    }
  ]
}

The program

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "time"
)

type problem struct {
    Code      string `json:"code"`
    Detail    string `json:"detail"`
    RequestID string `json:"requestId"`
}

type result struct {
    Rows [][]any `json:"rows"`
}

func query(ctx context.Context) (result, error) {
    body := map[string]any{
        "dimensions": []any{map[string]any{"field": "order_date", "grain": "week"}},
        "measures":   []any{map[string]any{"field": "revenue"}},
        "filters": []any{map[string]any{
            "field": "order_date",
            "op":    "date_range",
            "range": map[string]any{"kind": "relative", "preset": "last_n_weeks", "n": 2},
        }},
    }
    raw, err := json.Marshal(body)
    if err != nil {
        return result{}, err
    }

    url := "https://api.usehalo.net/v1/developer/datasets/" + os.Getenv("HALO_DATASET_ID") + "/query"
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
    if err != nil {
        return result{}, err
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("HALO_API_KEY"))
    req.Header.Set("Content-Type", "application/json")

    res, err := http.DefaultClient.Do(req)
    if err != nil {
        return result{}, err
    }
    defer res.Body.Close()

    data, err := io.ReadAll(res.Body)
    if err != nil {
        return result{}, err
    }
    if res.StatusCode != http.StatusOK {
        var p problem
        _ = json.Unmarshal(data, &p)
        return result{}, fmt.Errorf("halo answered %d %s: %s (request %s)", res.StatusCode, p.Code, p.Detail, p.RequestID)
    }
    var out result
    return out, json.Unmarshal(data, &out)
}

// message words the two weeks. Weeks with no orders have no row, so it says so.
func message(rows [][]any) (string, error) {
    if len(rows) == 0 {
        return "No revenue in the last two weeks.", nil
    }
    last, ok := rows[len(rows)-1][1].(float64)
    if !ok {
        return "", fmt.Errorf("revenue isn't a number: %v", rows[len(rows)-1][1])
    }
    if len(rows) == 1 {
        return fmt.Sprintf("Revenue in the latest week with orders: %.0f.", last), nil
    }
    before, ok := rows[0][1].(float64)
    if !ok || before == 0 {
        return fmt.Sprintf("Revenue last week: %.0f.", last), nil
    }
    change := (last - before) / before * 100
    direction := "up"
    if change < 0 {
        direction, change = "down", -change
    }
    return fmt.Sprintf("Revenue last week: %.0f, %s %.1f%% on the week before.", last, direction, change), nil
}

func post(ctx context.Context, text string) error {
    raw, err := json.Marshal(map[string]string{"text": text})
    if err != nil {
        return err
    }
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, os.Getenv("SLACK_WEBHOOK_URL"), bytes.NewReader(raw))
    if err != nil {
        return err
    }
    req.Header.Set("Content-Type", "application/json")
    res, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer res.Body.Close()
    if res.StatusCode != http.StatusOK {
        return fmt.Errorf("slack answered %d", res.StatusCode)
    }
    return nil
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
    defer cancel()

    r, err := query(ctx)
    if err != nil {
        log.Fatal(err)
    }
    text, err := message(r.Rows)
    if err != nil {
        log.Fatal(err)
    }
    if err := post(ctx, text); err != nil {
        log.Fatal(err)
    }
}

Try it, then build it and schedule it for Monday mornings:

go run main.go
go build -o digest main.go
0 8 * * 1 cd /opt/halo-digest && ./digest

Give the job its secrets from your scheduler's secret store rather than the crontab.

Keep it honest

  • Dates are your organization's. The weeks are computed in its time zone. Add "timeZone": "America/Denver" to the body to fix one.

  • It doesn't retry. For a weekly job, a failure in your scheduler's logs is often enough. To retry, take the helper from Snippets.

  • Failures are silent to the channel. The program exits non-zero and posts nothing, so have your scheduler alert on that. A key can stop for reasons that aren't about this job: see What stops a key.

  • It's what one person can see. The number follows the key's person, including their row policies. If they only see one region, so will the channel.

  • The channel is outside Halo. Post only numbers everyone in it may see.

  • Roll the key when Halo says it's about to end: see Roll a key.