Skip to content

Commit 36ed3bf

Browse files
committed
Add Elasticsearch index-freshness check type
1 parent efdccd9 commit 36ed3bf

9 files changed

Lines changed: 195 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
*.db-wal
44
*.db-shm
55
gjallar.yaml
6+
create-gjallar-user-mediainsights.sql

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
All notable changes to Gjallar are documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
55

6+
## [0.7.0] - 2026-07-06
7+
8+
### Added
9+
10+
- Elasticsearch check type (`type: elasticsearch`): index freshness measured
11+
as hours since `max(timestamp_field)`, evaluated against a rule (e.g. "< 3").
12+
Fields: `url`, `index`, `timestamp_field`, `rule`.
13+
614
## [0.6.0] - 2026-07-06
715

816
### Added

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ HTMX-refreshed), and alerts you when something goes down — and when it recover
1616
| `oracle` | SQL query result against a rule (pure-Go go-ora driver, no Oracle client needed) |
1717
| `ping` | ICMP echo (privileged or unprivileged) |
1818
| `redis` | TCP connect + optional `AUTH` + `PING`/`+PONG` |
19+
| `elasticsearch` | freshness: hours since `max(timestamp_field)` in an index, checked against a rule |
1920
| `prometheus` | Fetches a `/metrics` route and evaluates a rule against metric values, with optional label selectors |
2021

2122
Rules: `> N`, `>= N`, `< N`, `<= N`, `== x`, `!= x`, `~ regex`, `rows > 0` (row count).

gjallar.example.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ monitors:
9696
port: 6379 # default 6379
9797
password: "${REDIS_PASSWORD}" # omit if no AUTH
9898

99+
# --- Elasticsearch index freshness: hours since max(timestamp_field) ---
100+
- name: es-broadcasts
101+
type: elasticsearch
102+
url: "http://es-host:9200"
103+
index: "broadcasts"
104+
timestamp_field: "start_date"
105+
rule: "< 3" # alert if the newest document is more than 3h old
106+
99107
# --- Prometheus metrics: rule must hold for every matching series ---
100108
- name: node1-disk
101109
type: prometheus

internal/check/check.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ func New(m config.Monitor) (Checker, error) {
4141
c, err = newPingCheck(m)
4242
case "redis":
4343
c, err = newRedisCheck(m)
44+
case "elasticsearch":
45+
c, err = newESCheck(m)
4446
case "prometheus":
4547
c, err = newPromCheck(m)
4648
default:

internal/check/elasticsearch.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package check
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"strconv"
9+
"strings"
10+
"time"
11+
12+
"gjallar/internal/config"
13+
)
14+
15+
// esCheck measures the freshness of an Elasticsearch index: the number of hours
16+
// between now and max(timestamp_field), evaluated against the rule (e.g. "< 3").
17+
// This is the end-of-chain freshness signal — when an indexing worker stalls,
18+
// max(timestamp_field) stops advancing and the lag grows without bound.
19+
type esCheck struct {
20+
baseURL string
21+
index string
22+
tsField string
23+
rule *Rule
24+
client *http.Client
25+
}
26+
27+
func newESCheck(m config.Monitor) (*esCheck, error) {
28+
rule, err := ParseRule(m.Rule)
29+
if err != nil {
30+
return nil, err
31+
}
32+
if rule.TargetsRows() {
33+
return nil, fmt.Errorf("rule %q: row count rules do not apply to elasticsearch", m.Rule)
34+
}
35+
return &esCheck{
36+
baseURL: strings.TrimRight(m.URL, "/"),
37+
index: m.Index,
38+
tsField: m.TimestampField,
39+
rule: rule,
40+
client: &http.Client{},
41+
}, nil
42+
}
43+
44+
func (c *esCheck) Check(ctx context.Context) (bool, string) {
45+
body := fmt.Sprintf(`{"size":0,"aggs":{"max_ts":{"max":{"field":%q}}}}`, c.tsField)
46+
url := c.baseURL + "/" + c.index + "/_search"
47+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body))
48+
if err != nil {
49+
return false, err.Error()
50+
}
51+
req.Header.Set("Content-Type", "application/json")
52+
53+
resp, err := c.client.Do(req)
54+
if err != nil {
55+
return false, err.Error()
56+
}
57+
defer resp.Body.Close()
58+
if resp.StatusCode < 200 || resp.StatusCode > 299 {
59+
return false, fmt.Sprintf("status %d querying %s", resp.StatusCode, c.index)
60+
}
61+
62+
var parsed struct {
63+
Aggregations struct {
64+
MaxTS struct {
65+
Value float64 `json:"value"` // epoch millis
66+
} `json:"max_ts"`
67+
} `json:"aggregations"`
68+
}
69+
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
70+
return false, fmt.Sprintf("parsing response: %v", err)
71+
}
72+
ms := parsed.Aggregations.MaxTS.Value
73+
if ms <= 0 {
74+
return false, fmt.Sprintf("no %q value (empty index?)", c.tsField)
75+
}
76+
77+
last := time.UnixMilli(int64(ms))
78+
lagHours := time.Since(last).Hours()
79+
if err := c.rule.EvalValue(strconv.FormatFloat(lagHours, 'f', 2, 64)); err != nil {
80+
return false, fmt.Sprintf("freshness lag %.1fh (last %s): %v",
81+
lagHours, last.UTC().Format("2006-01-02 15:04 MST"), err)
82+
}
83+
return true, ""
84+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package check
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"gjallar/internal/config"
13+
)
14+
15+
func esMonitor(url, rule string) config.Monitor {
16+
return config.Monitor{
17+
Name: "t", Type: "elasticsearch", URL: url, Index: "broadcasts",
18+
TimestampField: "start_date", Rule: rule,
19+
Timeout: config.Duration(5 * time.Second),
20+
}
21+
}
22+
23+
// esServer returns a fake ES that reports max(start_date) = now - lag.
24+
func esServer(t *testing.T, lag time.Duration) *httptest.Server {
25+
t.Helper()
26+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+
if !strings.HasSuffix(r.URL.Path, "/broadcasts/_search") {
28+
w.WriteHeader(http.StatusNotFound)
29+
return
30+
}
31+
ms := time.Now().Add(-lag).UnixMilli()
32+
fmt.Fprintf(w, `{"aggregations":{"max_ts":{"value":%d.0}}}`, ms)
33+
}))
34+
t.Cleanup(srv.Close)
35+
return srv
36+
}
37+
38+
func TestESCheckFresh(t *testing.T) {
39+
srv := esServer(t, 30*time.Minute) // 0.5h lag
40+
c, _ := newESCheck(esMonitor(srv.URL, "< 3"))
41+
if ok, msg := c.Check(context.Background()); !ok {
42+
t.Errorf("expected ok, got %q", msg)
43+
}
44+
}
45+
46+
func TestESCheckStale(t *testing.T) {
47+
srv := esServer(t, 13*time.Hour) // the incident: 13h lag
48+
c, _ := newESCheck(esMonitor(srv.URL, "< 3"))
49+
ok, msg := c.Check(context.Background())
50+
if ok || !strings.Contains(msg, "freshness lag") {
51+
t.Errorf("got ok=%v msg=%q", ok, msg)
52+
}
53+
}
54+
55+
func TestESCheckEmpty(t *testing.T) {
56+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
57+
fmt.Fprint(w, `{"aggregations":{"max_ts":{"value":null}}}`)
58+
}))
59+
defer srv.Close()
60+
c, _ := newESCheck(esMonitor(srv.URL, "< 3"))
61+
if ok, msg := c.Check(context.Background()); ok || !strings.Contains(msg, "empty index") {
62+
t.Errorf("got ok=%v msg=%q", ok, msg)
63+
}
64+
}
65+
66+
func TestESCheckDown(t *testing.T) {
67+
c, _ := newESCheck(esMonitor("http://127.0.0.1:1", "< 3"))
68+
if ok, _ := c.Check(context.Background()); ok {
69+
t.Error("expected failure")
70+
}
71+
}

internal/config/config.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,15 @@ type Monitor struct {
9191
// prometheus
9292
Metric string `yaml:"metric"`
9393
Labels map[string]string `yaml:"labels"`
94+
95+
// elasticsearch
96+
Index string `yaml:"index"`
97+
TimestampField string `yaml:"timestamp_field"` // freshness = hours since max(this field)
9498
}
9599

96100
var monitorTypes = map[string]bool{
97101
"http": true, "postgres": true, "oracle": true, "ping": true, "prometheus": true, "redis": true,
102+
"elasticsearch": true,
98103
}
99104

100105
// envRe matches ${VAR} only; a bare $ is left alone so regex rules like
@@ -238,7 +243,7 @@ func (c *Config) Validate() error {
238243

239244
func (m *Monitor) validate() error {
240245
if !monitorTypes[m.Type] {
241-
return fmt.Errorf("unknown type %q (supported: http, postgres, oracle, ping, prometheus, redis)", m.Type)
246+
return fmt.Errorf("unknown type %q (supported: http, postgres, oracle, ping, prometheus, redis, elasticsearch)", m.Type)
242247
}
243248
switch m.Type {
244249
case "http":
@@ -274,6 +279,19 @@ func (m *Monitor) validate() error {
274279
if m.Rule == "" {
275280
return fmt.Errorf("rule is required")
276281
}
282+
case "elasticsearch":
283+
if m.URL == "" {
284+
return fmt.Errorf("url is required")
285+
}
286+
if m.Index == "" {
287+
return fmt.Errorf("index is required")
288+
}
289+
if m.TimestampField == "" {
290+
return fmt.Errorf("timestamp_field is required")
291+
}
292+
if m.Rule == "" {
293+
return fmt.Errorf("rule is required")
294+
}
277295
}
278296
return nil
279297
}

test.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
for h in 10.2.70.100 10.2.70.101 10.2.70.103; do ssh -t yacuser@$h "sudo pg_enc -m -k /etc/pgpool2/.pgpoolkey -u gjallar '$(grep -m1 PASSWORD create-gjallar-user-mediainsights.sql | sed "s/.*PASSWORD '//;s/'.*//")' && sudo systemctl reload pgpool2"; done

0 commit comments

Comments
 (0)