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
Make a key at the keys page with only
data.query, named something like "Slack digest".Find your dataset and fields with the Quickstart. This uses
order_dateandrevenue.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.go0 8 * * 1 cd /opt/halo-digest && ./digestGive 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.