Skip to content

Commit 6553928

Browse files
committed
feat: Stackryze Edge monitoring agent
- Runs on customer compute; measures DNS resolution latency + target reachability - Aggregates locally (p50/p95, success%, targets up/down); pushes only aggregates - Pure stdlib Go; Bearer token; Docker + k8s example; CI + GHCR release
0 parents  commit 6553928

9 files changed

Lines changed: 475 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: ci
2+
on:
3+
push:
4+
branches: [main, dev]
5+
pull_request:
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
build:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
- uses: actions/setup-go@v5
16+
with:
17+
go-version-file: "go.mod"
18+
cache: true
19+
- run: go build ./...
20+
- run: go vet ./...

.github/workflows/docker.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: docker
2+
on:
3+
push:
4+
branches: [main]
5+
tags: ["v*"]
6+
7+
permissions:
8+
contents: read
9+
packages: write
10+
11+
jobs:
12+
build-push:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
- uses: docker/setup-buildx-action@v3
17+
- uses: docker/login-action@v3
18+
with:
19+
registry: ghcr.io
20+
username: ${{ github.actor }}
21+
password: ${{ secrets.GITHUB_TOKEN }}
22+
- id: meta
23+
uses: docker/metadata-action@v5
24+
with:
25+
images: ghcr.io/${{ github.repository }}
26+
tags: |
27+
type=ref,event=branch
28+
type=semver,pattern={{version}}
29+
type=semver,pattern={{major}}.{{minor}}
30+
type=sha
31+
- uses: docker/build-push-action@v6
32+
with:
33+
context: .
34+
push: true
35+
tags: ${{ steps.meta.outputs.tags }}
36+
labels: ${{ steps.meta.outputs.labels }}

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
stackryze-edge
2+
/sredge
3+
dist/
4+
*.exe
5+
*.test
6+
*.out
7+
.env

Dockerfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM golang:1.23-alpine AS build
2+
WORKDIR /src
3+
COPY go.mod ./
4+
RUN go mod download
5+
COPY . .
6+
RUN CGO_ENABLED=0 go build -o /stackryze-edge .
7+
8+
FROM gcr.io/distroless/static-debian12:nonroot
9+
COPY --from=build /stackryze-edge /stackryze-edge
10+
ENTRYPOINT ["/stackryze-edge"]

README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Stackryze Edge Agent
2+
3+
A lightweight monitoring agent you run on **your own compute** (a VM, container,
4+
or Kubernetes) in the regions you care about. It measures DNS resolution latency
5+
and target reachability for your Stackryze zones from that vantage point, then
6+
pushes only **aggregated metrics** to the Stackryze control plane. The analysis
7+
UI lives in the Stackryze dashboard (dns.stackryze.com).
8+
9+
```
10+
your region (VM / pod)
11+
12+
stackryze-edge ──resolve + dial──► your zones' records
13+
│ aggregates locally (latency p50/p95, success %, targets up/down)
14+
▼ Bearer token
15+
api.stackryze.com/api/edge/metrics
16+
17+
18+
Stackryze dashboard → Edge monitoring
19+
```
20+
21+
No raw queries ever leave your infrastructure — only per-window aggregates.
22+
Runs entirely on your compute, so it never touches Stackryze's serving path.
23+
24+
## Configuration (env)
25+
26+
| Variable | Required | Default | Description |
27+
|----------|----------|---------|-------------|
28+
| `STACKRYZE_API_TOKEN` | yes || Token with **write** scope (Settings → API tokens) |
29+
| `STACKRYZE_API_URL` | no | `https://api.stackryze.com/api` | API base |
30+
| `STACKRYZE_EDGE_REGION` | no | `default` | Vantage-point label shown in the UI (e.g. `mumbai`) |
31+
| `STACKRYZE_EDGE_AGENT_ID` | no | `<region>-<hostname>` | Stable agent identifier |
32+
| `STACKRYZE_EDGE_INTERVAL` | no | `60` | Seconds between cycles (min 15) |
33+
| `STACKRYZE_EDGE_ZONES` | no | (all your zones) | Comma list to monitor; empty = auto-discover |
34+
| `STACKRYZE_EDGE_RESOLVERS` | no | `8.8.8.8,1.1.1.1,9.9.9.9` | Resolver IPs to test against |
35+
36+
## Run
37+
38+
```bash
39+
STACKRYZE_API_TOKEN=sk_dns_xxx \
40+
STACKRYZE_EDGE_REGION=mumbai \
41+
./stackryze-edge
42+
```
43+
44+
### Docker
45+
46+
```bash
47+
docker run -d --name stackryze-edge \
48+
-e STACKRYZE_API_TOKEN=sk_dns_xxx \
49+
-e STACKRYZE_EDGE_REGION=mumbai \
50+
ghcr.io/stackryze/stackryze-edge:latest
51+
```
52+
53+
### Kubernetes (one agent per region)
54+
55+
```yaml
56+
apiVersion: apps/v1
57+
kind: Deployment
58+
metadata:
59+
name: stackryze-edge
60+
spec:
61+
replicas: 1
62+
selector:
63+
matchLabels: { app: stackryze-edge }
64+
template:
65+
metadata:
66+
labels: { app: stackryze-edge }
67+
spec:
68+
containers:
69+
- name: agent
70+
image: ghcr.io/stackryze/stackryze-edge:latest
71+
env:
72+
- name: STACKRYZE_EDGE_REGION
73+
value: mumbai
74+
- name: STACKRYZE_API_TOKEN
75+
valueFrom:
76+
secretKeyRef: { name: stackryze-dns, key: token }
77+
```
78+
79+
## What it measures
80+
81+
Per zone, per cycle:
82+
- **Resolution latency** (p50/p95) resolving the apex across the configured resolvers.
83+
- **Success rate** of those lookups.
84+
- **Target reachability** — dials each resolved IP on `:443` (up/down counts).
85+
86+
## Build
87+
88+
Requires Go 1.22+. Pure standard library, no external dependencies.
89+
90+
```bash
91+
go build -o stackryze-edge .
92+
```

client.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"strings"
11+
"time"
12+
)
13+
14+
// stackryzeClient talks to the Stackryze control plane with a Bearer token.
15+
type stackryzeClient struct {
16+
baseURL string
17+
token string
18+
http *http.Client
19+
}
20+
21+
func newClient(baseURL, token string) *stackryzeClient {
22+
if baseURL == "" {
23+
baseURL = "https://api.stackryze.com/api"
24+
}
25+
return &stackryzeClient{
26+
baseURL: strings.TrimRight(baseURL, "/"),
27+
token: token,
28+
http: &http.Client{Timeout: 20 * time.Second},
29+
}
30+
}
31+
32+
func (c *stackryzeClient) do(ctx context.Context, method, path string, body any, out any) error {
33+
var reader io.Reader
34+
if body != nil {
35+
b, _ := json.Marshal(body)
36+
reader = bytes.NewReader(b)
37+
}
38+
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
39+
if err != nil {
40+
return err
41+
}
42+
req.Header.Set("Authorization", "Bearer "+c.token)
43+
req.Header.Set("Content-Type", "application/json")
44+
res, err := c.http.Do(req)
45+
if err != nil {
46+
return err
47+
}
48+
defer res.Body.Close()
49+
data, _ := io.ReadAll(res.Body)
50+
if res.StatusCode >= 400 {
51+
return fmt.Errorf("%s %s: %s (%s)", method, path, res.Status, strings.TrimSpace(string(data)))
52+
}
53+
if out != nil && len(data) > 0 {
54+
return json.Unmarshal(data, out)
55+
}
56+
return nil
57+
}
58+
59+
// discoverZones lists the account's zone names.
60+
func (c *stackryzeClient) discoverZones(ctx context.Context) ([]string, error) {
61+
var resp struct {
62+
Zones []struct {
63+
Name string `json:"name"`
64+
} `json:"zones"`
65+
}
66+
if err := c.do(ctx, "GET", "/zones", nil, &resp); err != nil {
67+
return nil, err
68+
}
69+
names := make([]string, 0, len(resp.Zones))
70+
for _, z := range resp.Zones {
71+
names = append(names, strings.TrimSuffix(z.Name, "."))
72+
}
73+
return names, nil
74+
}
75+
76+
// metricPayload is the aggregated snapshot pushed per zone per window.
77+
type metricPayload struct {
78+
ZoneName string `json:"zoneName"`
79+
Region string `json:"region"`
80+
AgentID string `json:"agentId"`
81+
WindowStart string `json:"windowStart"`
82+
WindowEnd string `json:"windowEnd"`
83+
Checks int `json:"checks"`
84+
SuccessRate float64 `json:"successRate"`
85+
LatencyP50 float64 `json:"latencyP50"`
86+
LatencyP95 float64 `json:"latencyP95"`
87+
TargetsUp int `json:"targetsUp"`
88+
TargetsDown int `json:"targetsDown"`
89+
}
90+
91+
func (c *stackryzeClient) pushMetrics(ctx context.Context, p metricPayload) error {
92+
return c.do(ctx, "POST", "/edge/metrics", p, nil)
93+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/stackryze/stackryze-edge
2+
3+
go 1.22

main.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"log"
6+
"os"
7+
"os/signal"
8+
"strconv"
9+
"strings"
10+
"syscall"
11+
"time"
12+
)
13+
14+
type config struct {
15+
apiURL string
16+
token string
17+
region string
18+
agentID string
19+
interval time.Duration
20+
zones []string
21+
resolvers []string
22+
dialTimeout time.Duration
23+
}
24+
25+
func env(key, def string) string {
26+
if v := os.Getenv(key); v != "" {
27+
return v
28+
}
29+
return def
30+
}
31+
32+
func splitList(s string) []string {
33+
var out []string
34+
for _, p := range strings.Split(s, ",") {
35+
if t := strings.TrimSpace(p); t != "" {
36+
out = append(out, t)
37+
}
38+
}
39+
return out
40+
}
41+
42+
func loadConfig() config {
43+
host, _ := os.Hostname()
44+
region := env("STACKRYZE_EDGE_REGION", "default")
45+
interval, _ := strconv.Atoi(env("STACKRYZE_EDGE_INTERVAL", "60"))
46+
if interval < 15 {
47+
interval = 15
48+
}
49+
resolvers := splitList(env("STACKRYZE_EDGE_RESOLVERS", "8.8.8.8,1.1.1.1,9.9.9.9"))
50+
return config{
51+
apiURL: env("STACKRYZE_API_URL", "https://api.stackryze.com/api"),
52+
token: os.Getenv("STACKRYZE_API_TOKEN"),
53+
region: region,
54+
agentID: env("STACKRYZE_EDGE_AGENT_ID", region+"-"+host),
55+
interval: time.Duration(interval) * time.Second,
56+
zones: splitList(os.Getenv("STACKRYZE_EDGE_ZONES")),
57+
resolvers: resolvers,
58+
dialTimeout: 4 * time.Second,
59+
}
60+
}
61+
62+
func main() {
63+
cfg := loadConfig()
64+
if cfg.token == "" {
65+
log.Fatal("STACKRYZE_API_TOKEN is required")
66+
}
67+
client := newClient(cfg.apiURL, cfg.token)
68+
69+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
70+
defer stop()
71+
72+
log.Printf("Stackryze Edge agent starting — region=%s agent=%s interval=%s", cfg.region, cfg.agentID, cfg.interval)
73+
74+
runCycle(ctx, client, cfg) // run once immediately
75+
ticker := time.NewTicker(cfg.interval)
76+
defer ticker.Stop()
77+
for {
78+
select {
79+
case <-ctx.Done():
80+
log.Println("shutting down")
81+
return
82+
case <-ticker.C:
83+
runCycle(ctx, client, cfg)
84+
}
85+
}
86+
}
87+
88+
func runCycle(ctx context.Context, client *stackryzeClient, cfg config) {
89+
zones := cfg.zones
90+
if len(zones) == 0 {
91+
discovered, err := client.discoverZones(ctx)
92+
if err != nil {
93+
log.Printf("zone discovery failed: %v", err)
94+
return
95+
}
96+
zones = discovered
97+
}
98+
if len(zones) == 0 {
99+
log.Println("no zones to monitor")
100+
return
101+
}
102+
103+
start := time.Now()
104+
for _, zone := range zones {
105+
snap := monitorZone(ctx, zone, cfg.resolvers, cfg.dialTimeout)
106+
payload := snap.toPayload(zone, cfg.region, cfg.agentID, start, time.Now())
107+
if err := client.pushMetrics(ctx, payload); err != nil {
108+
log.Printf("push failed for %s: %v", zone, err)
109+
continue
110+
}
111+
log.Printf("%s: checks=%d success=%.0f%% p50=%.1fms p95=%.1fms up=%d down=%d",
112+
zone, payload.Checks, payload.SuccessRate, payload.LatencyP50, payload.LatencyP95, payload.TargetsUp, payload.TargetsDown)
113+
}
114+
}

0 commit comments

Comments
 (0)