snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 03:23:42 +05:00
commit 89f5be005d
3997 changed files with 961970 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
WORKDIR /app
COPY . .
WORKDIR /app/telemetry/server
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o telemetry-server .
FROM alpine:latest
RUN apk --no-cache add ca-certificates \
&& addgroup -S appgroup \
&& adduser -S appuser -G appgroup
WORKDIR /home/appuser/
COPY --from=builder /app/telemetry/server/telemetry-server .
EXPOSE 8080
USER appuser
CMD ["./telemetry-server"]

97
telemetry/server/Makefile Normal file
View File

@@ -0,0 +1,97 @@
.PHONY: build run clean test deps proto integration-test test-all
# Build the telemetry server
build:
go build -o telemetry-server .
# Run the server in development mode
run:
go run . -port=8080 -dashboard=true -cleanup=1h -max-age=24h
# Run the server in production mode
run-prod:
./telemetry-server -port=8080 -dashboard=true -cleanup=24h -max-age=720h
# Clean build artifacts
clean:
rm -f telemetry-server
rm -f ../test/telemetry-server-test.log
go clean
# Run unit tests
test:
go test ./...
# Run integration tests
integration-test:
@echo "🧪 Running telemetry integration tests..."
cd ../../ && go run telemetry/test/integration.go
# Run all tests (unit + integration)
test-all: test integration-test
# Install dependencies
deps:
go mod download
go mod tidy
# Generate protobuf code (requires protoc)
proto:
cd .. && protoc --go_out=. --go_opt=paths=source_relative proto/telemetry.proto
# Build Docker image
docker-build:
docker build -t seaweedfs-telemetry .
# Run with Docker
docker-run:
docker run -p 8080:8080 seaweedfs-telemetry -port=8080 -dashboard=true
# Development with auto-reload (requires air: go install github.com/cosmtrek/air@latest)
dev:
air
# Check if protoc is available
check-protoc:
@which protoc > /dev/null || (echo "protoc is required for proto generation. Install from https://grpc.io/docs/protoc-installation/" && exit 1)
# Full development setup
setup: check-protoc deps proto build
# Run a quick smoke test
smoke-test: build
@echo "🔥 Running smoke test..."
@timeout 10s ./telemetry-server -port=18081 > /dev/null 2>&1 & \
SERVER_PID=$$!; \
sleep 2; \
if curl -s http://localhost:18081/health > /dev/null; then \
echo "✅ Smoke test passed - server responds to health check"; \
else \
echo "❌ Smoke test failed - server not responding"; \
exit 1; \
fi; \
kill $$SERVER_PID 2>/dev/null || true
# Continuous integration target
ci: deps proto build test integration-test
@echo "🎉 All CI tests passed!"
# Help
help:
@echo "Available targets:"
@echo " build - Build the telemetry server binary"
@echo " run - Run server in development mode"
@echo " run-prod - Run server in production mode"
@echo " clean - Clean build artifacts"
@echo " test - Run unit tests"
@echo " integration-test- Run integration tests"
@echo " test-all - Run all tests (unit + integration)"
@echo " deps - Install Go dependencies"
@echo " proto - Generate protobuf code"
@echo " docker-build - Build Docker image"
@echo " docker-run - Run with Docker"
@echo " dev - Run with auto-reload (requires air)"
@echo " smoke-test - Quick server health check"
@echo " setup - Full development setup"
@echo " ci - Continuous integration (all tests)"
@echo " help - Show this help"

View File

@@ -0,0 +1,231 @@
package api
import (
"encoding/json"
"io"
"net/http"
"strconv"
"time"
"github.com/seaweedfs/seaweedfs/telemetry/proto"
"github.com/seaweedfs/seaweedfs/telemetry/server/storage"
protobuf "google.golang.org/protobuf/proto"
)
type Handler struct {
storage *storage.PrometheusStorage
}
func NewHandler(storage *storage.PrometheusStorage) *Handler {
return &Handler{storage: storage}
}
func (h *Handler) CollectTelemetry(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
contentType := r.Header.Get("Content-Type")
// Only accept protobuf content type
if contentType != "application/x-protobuf" && contentType != "application/protobuf" {
http.Error(w, "Content-Type must be application/x-protobuf", http.StatusUnsupportedMediaType)
return
}
// Read protobuf request; real reports are well under 1 KB
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRequestBytes))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
req := &proto.TelemetryRequest{}
if err := protobuf.Unmarshal(body, req); err != nil {
http.Error(w, "Invalid protobuf data", http.StatusBadRequest)
return
}
data := req.Data
if data == nil {
http.Error(w, "Missing telemetry data", http.StatusBadRequest)
return
}
if err := validateTelemetryData(data); err != nil {
http.Error(w, "Invalid telemetry data: "+err.Error(), http.StatusBadRequest)
return
}
// Set timestamp if not provided
if data.Timestamp == 0 {
data.Timestamp = time.Now().Unix()
}
// Store the telemetry data
if err := h.storage.StoreTelemetry(data); err != nil {
http.Error(w, "Failed to store data", http.StatusInternalServerError)
return
}
// Return protobuf response
resp := &proto.TelemetryResponse{
Success: true,
Message: "Telemetry data received",
}
respData, err := protobuf.Marshal(resp)
if err != nil {
http.Error(w, "Failed to marshal response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/x-protobuf")
w.WriteHeader(http.StatusOK)
w.Write(respData)
}
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
stats, err := h.storage.GetStats()
if err != nil {
http.Error(w, "Failed to get stats", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
func (h *Handler) GetInstances(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
limitStr := r.URL.Query().Get("limit")
limit := 100 // default
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
limit = l
}
}
instances, err := h.storage.GetInstances(limit)
if err != nil {
http.Error(w, "Failed to get instances", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(instances)
}
func (h *Handler) GetMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
daysStr := r.URL.Query().Get("days")
days := 30 // default
if daysStr != "" {
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 && d <= 365 {
days = d
}
}
metrics, err := h.storage.GetMetrics(days)
if err != nil {
http.Error(w, "Failed to get metrics", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(metrics)
}
func (h *Handler) GetClusterSizes(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
days := 30 // default
if daysStr := r.URL.Query().Get("days"); daysStr != "" {
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 && d <= 365 {
days = d
}
}
limit := 20 // default
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
limit = l
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(h.storage.GetClusterSizeSeries(days, limit))
}
func (h *Handler) GetVersions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
days := 30 // default
if daysStr := r.URL.Query().Get("days"); daysStr != "" {
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 && d <= 365 {
days = d
}
}
limit := 8 // default, the number of colours the version stack has
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
limit = l
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(h.storage.GetVersionSeries(days, limit))
}
func (h *Handler) GetHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
clusterId := r.URL.Query().Get("cluster_id")
if clusterId == "" {
http.Error(w, "cluster_id is required", http.StatusBadRequest)
return
}
days := 90 // default
if daysStr := r.URL.Query().Get("days"); daysStr != "" {
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 && d <= 365 {
days = d
}
}
samples, ok := h.storage.GetHistory(clusterId, days)
if !ok {
http.Error(w, "Unknown cluster_id", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"cluster_id": clusterId,
"samples": samples,
})
}

View File

@@ -0,0 +1,140 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/seaweedfs/seaweedfs/telemetry/proto"
"github.com/seaweedfs/seaweedfs/telemetry/server/storage"
protobuf "google.golang.org/protobuf/proto"
)
// promauto registers on the global registry: one storage per test binary.
var testHandler = NewHandler(storage.NewPrometheusStorage())
func validReport() *proto.TelemetryData {
return &proto.TelemetryData{
TopologyId: "38422678-6a0d-4482-aa33-65b90010ac47",
Version: "4.40",
Os: "linux/amd64",
VolumeServerCount: 5,
TotalDiskBytes: 123 << 30,
TotalVolumeCount: 42,
FilerCount: 2,
BrokerCount: 1,
}
}
func postCollect(t *testing.T, h *Handler, body []byte, contentType string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/collect", bytes.NewReader(body))
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
h.CollectTelemetry(w, req)
return w
}
func marshalReport(t *testing.T, data *proto.TelemetryData) []byte {
t.Helper()
body, err := protobuf.Marshal(&proto.TelemetryRequest{Data: data})
if err != nil {
t.Fatalf("marshal: %v", err)
}
return body
}
func TestCollectTelemetryValidation(t *testing.T) {
h := testHandler
t.Run("valid report accepted", func(t *testing.T) {
if w := postCollect(t, h, marshalReport(t, validReport()), "application/x-protobuf"); w.Code != http.StatusOK {
t.Errorf("got %d, want 200: %s", w.Code, w.Body.String())
}
})
t.Run("enterprise version accepted", func(t *testing.T) {
data := validReport()
data.Version = "4.40-enterprise"
if w := postCollect(t, h, marshalReport(t, data), "application/x-protobuf"); w.Code != http.StatusOK {
t.Errorf("got %d, want 200: %s", w.Code, w.Body.String())
}
})
rejected := []struct {
name string
mutate func(*proto.TelemetryData)
}{
{"non-UUID topology_id", func(d *proto.TelemetryData) { d.TopologyId = "claude-diagnostic-probe" }},
{"empty topology_id", func(d *proto.TelemetryData) { d.TopologyId = "" }},
{"junk version", func(d *proto.TelemetryData) { d.Version = "probe" }},
{"version with suffix", func(d *proto.TelemetryData) { d.Version = "4.40-nightly" }},
{"junk os", func(d *proto.TelemetryData) { d.Os = "probe/probe" }},
{"os without slash", func(d *proto.TelemetryData) { d.Os = "linux" }},
{"negative count", func(d *proto.TelemetryData) { d.VolumeServerCount = -1 }},
{"absurd server count", func(d *proto.TelemetryData) { d.VolumeServerCount = 1_000_000 }},
{"absurd disk size", func(d *proto.TelemetryData) { d.TotalDiskBytes = 1 << 62 }},
}
for _, tc := range rejected {
t.Run(tc.name+" rejected", func(t *testing.T) {
data := validReport()
tc.mutate(data)
if w := postCollect(t, h, marshalReport(t, data), "application/x-protobuf"); w.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400: %s", w.Code, w.Body.String())
}
})
}
t.Run("wrong content type rejected", func(t *testing.T) {
if w := postCollect(t, h, marshalReport(t, validReport()), "application/json"); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("got %d, want 415", w.Code)
}
})
t.Run("oversized body rejected", func(t *testing.T) {
if w := postCollect(t, h, make([]byte, maxRequestBytes+1), "application/x-protobuf"); w.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400", w.Code)
}
})
}
// The dashboard looks confirmation windows up by the select's string value,
// so the stats JSON must key confirmed_by_days by decimal strings and carry
// unmet thresholds as zeros.
func TestStatsSerializedThresholds(t *testing.T) {
data := validReport()
data.TopologyId = "49533789-7b1e-4593-bb44-76ca1121bd58"
if w := postCollect(t, testHandler, marshalReport(t, data), "application/x-protobuf"); w.Code != http.StatusOK {
t.Fatalf("collect: got %d: %s", w.Code, w.Body.String())
}
req := httptest.NewRequest(http.MethodGet, "/api/stats", nil)
w := httptest.NewRecorder()
testHandler.GetStats(w, req)
if w.Code != http.StatusOK {
t.Fatalf("stats: got %d", w.Code)
}
var stats struct {
ConfirmedByDays map[string]int `json:"confirmed_by_days"`
}
if err := json.Unmarshal(w.Body.Bytes(), &stats); err != nil {
t.Fatalf("decode: %v", err)
}
if len(stats.ConfirmedByDays) != 5 {
t.Fatalf("confirmed_by_days = %v, want the 5 thresholds", stats.ConfirmedByDays)
}
for _, key := range []string{"1", "3", "7", "14", "30"} {
if _, ok := stats.ConfirmedByDays[key]; !ok {
t.Errorf("confirmed_by_days missing %q: %v", key, stats.ConfirmedByDays)
}
}
if stats.ConfirmedByDays["1"] < 1 {
t.Errorf("fresh cluster missing from the 1-day count: %v", stats.ConfirmedByDays)
}
if stats.ConfirmedByDays["30"] != 0 {
t.Errorf("unmet threshold not zero: %v", stats.ConfirmedByDays)
}
}

View File

@@ -0,0 +1,58 @@
package api
import (
"fmt"
"regexp"
"strings"
"github.com/seaweedfs/seaweedfs/telemetry/proto"
)
// The collect endpoint is anonymous, so reports can't be authenticated —
// these checks only reject values a real SeaweedFS master can't produce,
// to keep casual junk out of the collected data.
const (
maxRequestBytes = 4096
maxServerCount = 100_000 // volume servers / filers / brokers per cluster
maxVolumeCount = 100_000_000 // volumes per cluster
maxDiskBytes = uint64(1) << 60 // 1 EiB
)
var (
// TopologyId is a raft-generated UUID.
uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// Version is VERSION_NUMBER, e.g. "4.40", plus "-enterprise" for enterprise builds.
versionRe = regexp.MustCompile(`^\d+\.\d+(-enterprise)?$`)
validGoos = map[string]bool{
"linux": true, "darwin": true, "windows": true,
"freebsd": true, "openbsd": true, "netbsd": true,
}
validGoarch = map[string]bool{
"amd64": true, "arm64": true, "arm": true, "386": true,
"riscv64": true, "ppc64le": true, "s390x": true,
"mips64": true, "mips64le": true, "loong64": true,
}
)
func validateTelemetryData(data *proto.TelemetryData) error {
if !uuidRe.MatchString(data.TopologyId) {
return fmt.Errorf("topology_id is not a UUID")
}
if !versionRe.MatchString(data.Version) {
return fmt.Errorf("unrecognized version")
}
goos, goarch, ok := strings.Cut(data.Os, "/")
if !ok || !validGoos[goos] || !validGoarch[goarch] {
return fmt.Errorf("unrecognized os")
}
if data.VolumeServerCount < 0 || data.VolumeServerCount > maxServerCount ||
data.FilerCount < 0 || data.FilerCount > maxServerCount ||
data.BrokerCount < 0 || data.BrokerCount > maxServerCount ||
data.TotalVolumeCount < 0 || data.TotalVolumeCount > maxVolumeCount ||
data.TotalDiskBytes > maxDiskBytes {
return fmt.Errorf("values out of range")
}
return nil
}

View File

@@ -0,0 +1,591 @@
package dashboard
import (
"net/http"
)
type Handler struct{}
func NewHandler() *Handler {
return &Handler{}
}
func (h *Handler) ServeIndex(w http.ResponseWriter, r *http.Request) {
html := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SeaweedFS Telemetry Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-value {
font-size: 2em;
font-weight: bold;
color: #2196F3;
}
.stat-label {
color: #666;
margin-top: 5px;
}
.stat-label select {
border: none;
background: none;
color: inherit;
font: inherit;
padding: 0;
cursor: pointer;
text-decoration: underline dotted;
}
.chart-container {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.chart-title {
font-size: 1.2em;
font-weight: bold;
margin-bottom: 15px;
}
.chart-subtitle {
color: #666;
margin: -10px 0 15px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.cluster-lookup {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
.cluster-lookup input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: monospace;
}
.cluster-lookup button {
padding: 8px 20px;
border: none;
border-radius: 4px;
background: #2196F3;
color: white;
cursor: pointer;
}
.error {
background: #ffebee;
color: #c62828;
padding: 15px;
border-radius: 4px;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>SeaweedFS Telemetry Dashboard</h1>
<p>Privacy-respecting usage analytics for SeaweedFS</p>
</div>
<div id="loading" class="loading">Loading telemetry data...</div>
<div id="error" class="error" style="display: none;"></div>
<div id="dashboard" style="display: none;">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="totalInstances">-</div>
<div class="stat-label">Total Instances (30 days)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="activeInstances">-</div>
<div class="stat-label">Active Instances (7 days)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="confirmedInstances">-</div>
<div class="stat-label">Confirmed Clusters
(<select id="confirmDays" onchange="updateConfirmed()">
<option value="1">1+</option>
<option value="3">3+</option>
<option value="7" selected>7+</option>
<option value="14">14+</option>
<option value="30">30+</option>
</select> days)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="totalVersions">-</div>
<div class="stat-label">Different Versions</div>
</div>
<div class="stat-card">
<div class="stat-value" id="totalOS">-</div>
<div class="stat-label">Operating Systems</div>
</div>
</div>
<div class="chart-container">
<div class="chart-title">Version Distribution</div>
<div style="position: relative; height: 320px;">
<canvas id="versionChart"></canvas>
</div>
</div>
<div class="chart-container">
<div class="chart-title">Versions Over Time</div>
<div class="chart-subtitle" id="versionSeriesTotal"></div>
<div style="position: relative; height: 420px;">
<canvas id="versionSeriesChart"></canvas>
</div>
</div>
<div class="chart-container">
<div class="chart-title">Operating System Distribution</div>
<div style="position: relative; height: 320px;">
<canvas id="osChart"></canvas>
</div>
</div>
<div class="chart-container">
<div class="chart-title">Cluster Sizes Over Time</div>
<div class="chart-subtitle" id="clusterSizesTotal"></div>
<div style="position: relative; height: 420px;">
<canvas id="clusterSizeChart"></canvas>
</div>
</div>
<div class="chart-container">
<div class="chart-title">Volume Servers Over Time</div>
<div class="chart-subtitle" id="clusterServersTotal"></div>
<div style="position: relative; height: 420px;">
<canvas id="serverChart"></canvas>
</div>
</div>
<div class="chart-container">
<div class="chart-title">Per-Cluster History</div>
<div class="cluster-lookup">
<input type="text" id="clusterIdInput" placeholder="cluster UUID"
onkeydown="if (event.key === 'Enter') loadClusterHistory()">
<button onclick="loadClusterHistory()">Load</button>
</div>
<div id="clusterHistoryError" class="error" style="display: none;"></div>
<div id="clusterHistoryCharts" style="display: none;">
<canvas id="clusterDiskChart" width="400" height="150"></canvas>
<canvas id="clusterVolumeChart" width="400" height="150"></canvas>
</div>
</div>
</div>
</div>
<script>
let charts = {};
let clusterSizeIds = [];
async function loadDashboard() {
try {
// Load stats
const statsResponse = await fetch('/api/stats');
const stats = await statsResponse.json();
// Load per-cluster sizes over time
const sizesResponse = await fetch('/api/cluster-sizes?days=30&limit=20');
const sizes = await sizesResponse.json();
// Load the fleet's version make-up over time
const versionsResponse = await fetch('/api/versions?days=30&limit=8');
const versions = await versionsResponse.json();
// Show the dashboard before drawing into it: a canvas in a
// display:none container measures zero, and a pie sized from
// that never grows back.
document.getElementById('loading').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
updateStats(stats);
updateCharts(stats);
updateVersions(versions);
updateClusterSizes(sizes);
} catch (error) {
console.error('Error loading dashboard:', error);
showError('Failed to load telemetry data: ' + error.message);
}
}
let latestStats = {};
function updateStats(stats) {
latestStats = stats;
document.getElementById('totalInstances').textContent = stats.total_instances || 0;
document.getElementById('activeInstances').textContent = stats.active_instances || 0;
updateConfirmed();
document.getElementById('totalVersions').textContent = Object.keys(stats.versions || {}).length;
document.getElementById('totalOS').textContent = Object.keys(stats.os_distribution || {}).length;
}
// Servers from before confirmed_by_days fall back to the fixed
// 7-day count.
function updateConfirmed() {
const days = document.getElementById('confirmDays').value;
const byDays = latestStats.confirmed_by_days || {};
const count = byDays[days] !== undefined ? byDays[days] : latestStats.confirmed_instances;
document.getElementById('confirmedInstances').textContent = count || 0;
}
function updateCharts(stats) {
createPieChart('versionChart', 'Version Distribution', stats.versions || {});
createPieChart('osChart', 'Operating System Distribution', stats.os_distribution || {});
}
function createPieChart(canvasId, title, data) {
const ctx = document.getElementById(canvasId).getContext('2d');
if (charts[canvasId]) {
charts[canvasId].destroy();
}
const labels = Object.keys(data);
const values = Object.values(data);
charts[canvasId] = new Chart(ctx, {
type: 'pie',
data: {
labels: labels,
datasets: [{
data: values,
backgroundColor: [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0',
'#9966FF', '#FF9F40', '#FF6384', '#C9CBCF'
]
}]
},
options: {
responsive: true,
// Without this the canvas keeps its 2:1 attribute ratio at
// the card's full width, drawing a pie taller than the card.
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
}
function createLineChart(canvasId, label, labels, data, color) {
const ctx = document.getElementById(canvasId).getContext('2d');
if (charts[canvasId]) {
charts[canvasId].destroy();
}
charts[canvasId] = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: label,
data: data,
borderColor: color,
backgroundColor: color + '20',
fill: true,
tension: 0.1
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// Decimal units, so the round numbers the chart picks for its ticks
// come out as round labels.
function formatBytes(bytes) {
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
let value = bytes || 0, unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit++;
}
return (unit === 0 ? value : value.toFixed(value >= 100 ? 0 : 1)) + ' ' + units[unit];
}
// Fixed hues rather than the evenly spaced ones the cluster stacks use:
// a handful of versions is a set you read, and evenly spaced hues put
// pairs next to each other that colour-blind readers can't separate.
const versionColors = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100',
'#e87ba4', '#008300', '#4a3aa7', '#e34948'];
// One stacked band per version: the band is how many clusters ran that
// version that day, the top of the stack is the confirmed fleet, so the
// chart shows both how it grows and what it upgrades to. The pie above
// it is the same fleet on the last of these days.
function updateVersions(series) {
const versions = series.versions || [];
const dates = series.dates || [];
const total = series.total_clusters || 0;
document.getElementById('versionSeriesTotal').textContent =
total + ' cluster' + (total === 1 ? '' : 's') + ' on ' + (dates[dates.length - 1] || 'no data');
// Newest release on the floor, oldest on top: the current release
// is the band being read, and one anchored to the baseline reads
// straight off the axis instead of riding on everything below it.
// Colours follow the same order, so a release keeps its colour as
// older ones age out from the top.
const datasets = versions.slice().reverse().map((v, i) =>
band(v.version, v.clusters, '#ffffff', versionColors[i % versionColors.length], 2));
if (series.other) {
datasets.push(band('other (' + series.other.count + ' versions)', series.other.clusters,
'#ffffff', '#9a9a94', 2));
}
stackedArea('versionSeriesChart', dates, datasets, value => value, { plugins: [bandLabels] });
}
// Writes each version into its own band, so the chart reads without
// matching colours against the legend. Bands too thin to hold the text
// keep it, and the halo carries it over whatever it crosses.
const bandLabels = {
id: 'bandLabels',
afterDatasetsDraw(chart) {
const ctx = chart.ctx;
ctx.save();
ctx.font = '600 12px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineJoin = 'round';
chart.data.datasets.forEach((dataset, d) => {
const meta = chart.getDatasetMeta(d);
if (meta.hidden) return;
// Label where the band is thickest, so the text has room.
let at = -1, thickest = 0;
dataset.data.forEach((value, i) => {
if (value > thickest) {
thickest = value;
at = i;
}
});
const point = at >= 0 && meta.data[at];
if (!point) return;
const below = d === 0 ? chart.scales.y.getPixelForValue(0)
: chart.getDatasetMeta(d - 1).data[at].y;
if (below - point.y < 18) return;
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
ctx.strokeText(dataset.label, point.x, (point.y + below) / 2);
ctx.fillStyle = '#1a1a1a';
ctx.fillText(dataset.label, point.x, (point.y + below) / 2);
});
ctx.restore();
}
};
// Disk usage and volume servers both drawn as one stacked band per
// cluster over time: the band is that cluster's share, the top of the
// stack is the fleet total. Clusters beyond the requested limit are
// summed into a trailing "other" band so the stack still adds up.
function updateClusterSizes(series) {
const clusters = series.clusters || [];
const dates = series.dates || [];
const count = series.cluster_count || 0;
const servers = series.total_servers || 0;
const across = ' across ' + count + ' cluster' + (count === 1 ? '' : 's') +
' on ' + (dates[dates.length - 1] || 'no data');
document.getElementById('clusterSizesTotal').textContent = formatBytes(series.total_disk) + across;
document.getElementById('clusterServersTotal').textContent =
servers + ' volume server' + (servers === 1 ? '' : 's') + across;
clusterSizeIds = clusters.map(c => c.cluster_id);
if (series.other) {
clusterSizeIds.push(null);
}
stackedChart('clusterSizeChart', dates, clusters, series.other, 'disk', formatBytes);
stackedChart('serverChart', dates, clusters, series.other, 'servers', value => value);
}
// The stacks share one cluster order, so a cluster keeps its colour and
// its legend entry across both of them.
function stackedChart(canvasId, dates, clusters, other, key, format) {
const datasets = clusters.map((c, i) => {
// Evenly spaced hues keep neighbouring bands distinguishable.
const hue = Math.round(i * 360 / clusters.length);
return band(c.cluster_id.slice(0, 8), c[key],
'hsl(' + hue + ', 65%, 45%)', 'hsla(' + hue + ', 65%, 55%, 0.75)');
});
if (other) {
datasets.push(band('other (' + other.count + ')', other[key],
'#9E9E9E', 'rgba(158, 158, 158, 0.6)'));
}
stackedArea(canvasId, dates, datasets, format, {
onClick: (event, elements) => {
const id = elements.length && clusterSizeIds[elements[0].datasetIndex];
if (id) {
document.getElementById('clusterIdInput').value = id;
loadClusterHistory();
}
},
// The legend shows shortened ids; the tooltip has room for the
// full one to paste into the lookup box.
label: item => (clusterSizeIds[item.datasetIndex] || item.dataset.label) + ': ' + format(item.raw)
});
}
// Draws the bands as one stack, so their heights add up to the day's
// total. hooks.onClick, hooks.label and hooks.plugins are optional.
function stackedArea(canvasId, dates, datasets, format, hooks) {
hooks = hooks || {};
const ctx = document.getElementById(canvasId).getContext('2d');
if (charts[canvasId]) {
charts[canvasId].destroy();
}
charts[canvasId] = new Chart(ctx, {
type: 'line',
data: { labels: dates, datasets: datasets },
plugins: hooks.plugins,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'band', intersect: false },
onClick: hooks.onClick,
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } },
tooltip: {
callbacks: {
label: hooks.label || (item => item.dataset.label + ': ' + format(item.raw))
}
}
},
scales: {
x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 } },
y: {
stacked: true,
beginAtZero: true,
ticks: { precision: 0, callback: value => format(value) }
}
}
}
});
}
// Hover and click resolve to the band the pointer is inside. Chart.js's
// built-in modes match the nearest line, which on a stack of thin bands
// is rarely the one under the pointer.
Chart.Interaction.modes.band = function(chart, event) {
const last = chart.data.labels.length - 1;
if (last < 0) return [];
const index = Math.min(Math.max(Math.round(chart.scales.x.getValueForPixel(event.x)), 0), last);
const value = chart.scales.y.getValueForPixel(event.y);
let stacked = 0;
for (let d = 0; d < chart.data.datasets.length; d++) {
stacked += chart.data.datasets[d].data[index] || 0;
if (value <= stacked) {
return [{ element: chart.getDatasetMeta(d).data[index], datasetIndex: d, index: index }];
}
}
return [];
};
function band(label, data, borderColor, backgroundColor, borderWidth) {
return {
label: label,
data: data,
borderColor: borderColor,
backgroundColor: backgroundColor,
borderWidth: borderWidth || 1,
pointRadius: 0,
pointHitRadius: 8,
fill: true,
tension: 0.1
};
}
async function loadClusterHistory() {
const id = document.getElementById('clusterIdInput').value.trim();
const errorDiv = document.getElementById('clusterHistoryError');
const chartsDiv = document.getElementById('clusterHistoryCharts');
if (!id) return;
errorDiv.style.display = 'none';
try {
const resp = await fetch('/api/history?cluster_id=' + encodeURIComponent(id) + '&days=90');
if (!resp.ok) {
throw new Error(resp.status === 404 ? 'Unknown cluster UUID' : 'Request failed: ' + resp.status);
}
const history = await resp.json();
const samples = history.samples || [];
if (samples.length === 0) {
throw new Error('No samples recorded for this cluster yet');
}
const dates = samples.map(s => new Date(s.ts * 1000).toISOString().slice(0, 10));
chartsDiv.style.display = 'block';
createLineChart('clusterDiskChart', 'Disk Usage (GB)', dates,
samples.map(s => Math.round(s.disk / (1024 * 1024 * 1024) * 100) / 100), '#FF9800');
createLineChart('clusterVolumeChart', 'Volumes', dates,
samples.map(s => s.volumes), '#9C27B0');
} catch (error) {
chartsDiv.style.display = 'none';
errorDiv.style.display = 'block';
errorDiv.textContent = error.message;
}
}
function showError(message) {
document.getElementById('loading').style.display = 'none';
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = message;
}
// Load dashboard on page load
loadDashboard();
// Refresh every 5 minutes
setInterval(loadDashboard, 5 * 60 * 1000);
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(html))
}

21
telemetry/server/go.mod Normal file
View File

@@ -0,0 +1,21 @@
module github.com/seaweedfs/seaweedfs/telemetry/server
go 1.26
require (
github.com/prometheus/client_golang v1.24.1
github.com/seaweedfs/seaweedfs v0.0.0-00010101000000-000000000000
google.golang.org/protobuf v1.36.12
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
golang.org/x/sys v0.47.0 // indirect
)
replace github.com/seaweedfs/seaweedfs => ../..

36
telemetry/server/go.sum Normal file
View File

@@ -0,0 +1,36 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

156
telemetry/server/main.go Normal file
View File

@@ -0,0 +1,156 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/seaweedfs/seaweedfs/telemetry/server/api"
"github.com/seaweedfs/seaweedfs/telemetry/server/dashboard"
"github.com/seaweedfs/seaweedfs/telemetry/server/storage"
)
var (
port = flag.Int("port", 8080, "HTTP server port")
enableCORS = flag.Bool("cors", true, "Enable CORS for dashboard")
logRequests = flag.Bool("log", true, "Log incoming requests")
enableDashboard = flag.Bool("dashboard", true, "Enable built-in dashboard (optional when using Grafana)")
cleanupInterval = flag.Duration("cleanup", 24*time.Hour, "Cleanup interval for old instances")
maxInstanceAge = flag.Duration("max-age", 90*24*time.Hour, "Maximum age for instances before cleanup")
stateFile = flag.String("state-file", "data/telemetry-state.json", "File for persisting in-memory state across restarts (empty to disable)")
saveInterval = flag.Duration("state-save", time.Hour, "How often to save changed state to -state-file")
)
func main() {
flag.Parse()
// Create Prometheus storage instance
store := storage.NewPrometheusStorage()
// Restore state from the previous run and keep saving it periodically,
// so deploys and restarts don't reset the collected metrics
if *stateFile != "" {
if n, err := store.LoadState(*stateFile); err != nil {
log.Printf("Failed to load state from %s: %v", *stateFile, err)
} else if n > 0 {
log.Printf("Restored %d instances from %s", n, *stateFile)
}
go func() {
ticker := time.NewTicker(*saveInterval)
defer ticker.Stop()
for range ticker.C {
if err := store.SaveStateIfDirty(*stateFile); err != nil {
log.Printf("Failed to save state to %s: %v", *stateFile, err)
}
}
}()
}
// Start cleanup routine
go func() {
ticker := time.NewTicker(*cleanupInterval)
defer ticker.Stop()
for range ticker.C {
store.CleanupOldInstances(*maxInstanceAge)
}
}()
// Setup HTTP handlers
mux := http.NewServeMux()
// Prometheus metrics endpoint
mux.Handle("/metrics", promhttp.Handler())
// API endpoints
apiHandler := api.NewHandler(store)
mux.HandleFunc("/api/collect", corsMiddleware(logMiddleware(apiHandler.CollectTelemetry)))
mux.HandleFunc("/api/stats", corsMiddleware(logMiddleware(apiHandler.GetStats)))
mux.HandleFunc("/api/instances", corsMiddleware(logMiddleware(apiHandler.GetInstances)))
mux.HandleFunc("/api/metrics", corsMiddleware(logMiddleware(apiHandler.GetMetrics)))
mux.HandleFunc("/api/history", corsMiddleware(logMiddleware(apiHandler.GetHistory)))
mux.HandleFunc("/api/cluster-sizes", corsMiddleware(logMiddleware(apiHandler.GetClusterSizes)))
mux.HandleFunc("/api/versions", corsMiddleware(logMiddleware(apiHandler.GetVersions)))
// Dashboard (optional)
if *enableDashboard {
dashboardHandler := dashboard.NewHandler()
mux.HandleFunc("/", corsMiddleware(dashboardHandler.ServeIndex))
mux.HandleFunc("/dashboard", corsMiddleware(dashboardHandler.ServeIndex))
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
}
// Health check
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
addr := fmt.Sprintf(":%d", *port)
log.Printf("Starting telemetry server on %s", addr)
log.Printf("Prometheus metrics: http://localhost%s/metrics", addr)
if *enableDashboard {
log.Printf("Dashboard: http://localhost%s/dashboard", addr)
}
log.Printf("Cleanup interval: %v, Max instance age: %v", *cleanupInterval, *maxInstanceAge)
server := &http.Server{Addr: addr, Handler: mux}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()
// On SIGTERM/SIGINT, stop serving and save state before exiting
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Printf("Shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
server.Shutdown(ctx)
if *stateFile != "" {
if err := store.SaveStateIfDirty(*stateFile); err != nil {
log.Printf("Failed to save state to %s: %v", *stateFile, err)
}
}
}
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if *enableCORS {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
}
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
func logMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if *logRequests {
start := time.Now()
next(w, r)
log.Printf("%s %s %s %v", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start))
} else {
next(w, r)
}
}
}

View File

@@ -0,0 +1,87 @@
package storage
import (
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/seaweedfs/seaweedfs/telemetry/proto"
)
func report(id, version string) *proto.TelemetryData {
return &proto.TelemetryData{
TopologyId: id,
Version: version,
Os: "linux/amd64",
VolumeServerCount: 1,
TotalDiskBytes: proto.MinDiskBytes,
TotalVolumeCount: 1,
}
}
func statsOf(t *testing.T, s *PrometheusStorage) map[string]interface{} {
t.Helper()
stats, err := s.GetStats()
if err != nil {
t.Fatalf("stats: %v", err)
}
return stats
}
func TestConfirmedClusters(t *testing.T) {
s := newPrometheusStorage(prometheus.NewRegistry())
// A single-day cluster is active but not confirmed; with no confirmed
// clusters yet, distributions fall back to all active clusters.
if err := s.StoreTelemetry(report("aaaaaaaa-0000-0000-0000-000000000001", "4.40")); err != nil {
t.Fatal(err)
}
stats := statsOf(t, s)
if stats["active_instances"] != 1 || stats["confirmed_instances"] != 0 {
t.Fatalf("day one: active=%v confirmed=%v, want 1/0", stats["active_instances"], stats["confirmed_instances"])
}
if v := stats["versions"].(map[string]int); v["4.40"] != 1 {
t.Fatalf("fallback distribution missing active cluster: %v", v)
}
// Give cluster A samples from the six previous days: now seen on 7
// distinct days.
s.mu.Lock()
id := "aaaaaaaa-0000-0000-0000-000000000001"
var older []HistorySample
for offset := -6; offset < 0; offset++ {
older = append(older, HistorySample{
Ts: time.Now().AddDate(0, 0, offset).Unix(),
TotalDiskBytes: 50,
})
}
s.histories[id] = append(older, s.histories[id]...)
s.mu.Unlock()
// A one-shot cluster B arrives (like an injected report): it counts as
// active, but the distributions now only reflect confirmed clusters.
if err := s.StoreTelemetry(report("bbbbbbbb-0000-0000-0000-000000000002", "9.99")); err != nil {
t.Fatal(err)
}
stats = statsOf(t, s)
if stats["active_instances"] != 2 || stats["confirmed_instances"] != 1 {
t.Fatalf("day seven: active=%v confirmed=%v, want 2/1", stats["active_instances"], stats["confirmed_instances"])
}
v := stats["versions"].(map[string]int)
if v["4.40"] != 1 {
t.Errorf("confirmed cluster missing from distribution: %v", v)
}
if _, ok := v["9.99"]; ok {
t.Errorf("one-shot cluster polluted the distribution: %v", v)
}
// Cluster A meets the 1/3/7-day thresholds, B only the 1-day one, and
// unmet thresholds are present as zero so the dashboard can show them.
byDays := stats["confirmed_by_days"].(map[int]int)
want := map[int]int{1: 2, 3: 1, 7: 1, 14: 0, 30: 0}
for threshold, expected := range want {
if got, ok := byDays[threshold]; !ok || got != expected {
t.Errorf("confirmed_by_days[%d] = %v (present=%v), want %d", threshold, got, ok, expected)
}
}
}

View File

@@ -0,0 +1,90 @@
package storage
import (
"path/filepath"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"github.com/seaweedfs/seaweedfs/telemetry/proto"
)
func counterValue(t *testing.T, c prometheus.Counter) float64 {
t.Helper()
var m dto.Metric
if err := c.Write(&m); err != nil {
t.Fatal(err)
}
return m.GetCounter().GetValue()
}
func TestClustersUnderTheFloorAreNotKept(t *testing.T) {
s := newPrometheusStorage(prometheus.NewRegistry())
// report() sits exactly on the floor, which is enough.
if err := s.StoreTelemetry(report("aaaaaaaa-0000-0000-0000-000000000001", "4.40")); err != nil {
t.Fatal(err)
}
small := report("bbbbbbbb-0000-0000-0000-000000000002", "4.40")
small.TotalDiskBytes = proto.MinDiskBytes - 1
if err := s.StoreTelemetry(small); err != nil {
t.Fatal(err)
}
if _, ok := s.instances[small.TopologyId]; ok {
t.Fatal("cluster under the floor was kept")
}
if _, ok := s.GetHistory(small.TopologyId, 90); ok {
t.Fatal("history kept for a cluster under the floor")
}
if stats := statsOf(t, s); stats["active_instances"] != 1 {
t.Errorf("active = %v, want the one cluster on the floor", stats["active_instances"])
}
if got := counterValue(t, s.telemetryReceived); got != 2 {
t.Errorf("received = %v, want 2", got)
}
if got := counterValue(t, s.reportsSkipped); got != 1 {
t.Errorf("skipped = %v, want 1", got)
}
}
// State written before the floor existed carries clusters under it; loading
// drops them and marks the state dirty so the next save sheds them from disk.
func TestLoadStateDropsClustersUnderTheFloor(t *testing.T) {
path := filepath.Join(t.TempDir(), "telemetry-state.json")
s := newPrometheusStorage(prometheus.NewRegistry())
kept := report("aaaaaaaa-0000-0000-0000-000000000001", "4.40")
if err := s.StoreTelemetry(kept); err != nil {
t.Fatal(err)
}
small := report("bbbbbbbb-0000-0000-0000-000000000002", "4.40")
small.TotalDiskBytes = proto.MinDiskBytes - 1
s.instances[small.TopologyId] = &telemetryData{TelemetryData: small, ReceivedAt: time.Now()}
s.histories[small.TopologyId] = []HistorySample{{Ts: time.Now().Unix(), TotalDiskBytes: small.TotalDiskBytes}}
if err := s.SaveStateIfDirty(path); err != nil {
t.Fatal(err)
}
s = newPrometheusStorage(prometheus.NewRegistry())
n, err := s.LoadState(path)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("loaded %d instances, want 1", n)
}
if _, ok := s.instances[small.TopologyId]; ok {
t.Error("cluster under the floor survived the load")
}
if _, ok := s.histories[small.TopologyId]; ok {
t.Error("history of a cluster under the floor survived the load")
}
if _, ok := s.instances[kept.TopologyId]; !ok {
t.Error("cluster on the floor was dropped")
}
if !s.dirty {
t.Error("dropping a cluster left the state clean, so it would stay on disk")
}
}

View File

@@ -0,0 +1,175 @@
package storage
import (
"time"
"github.com/seaweedfs/seaweedfs/telemetry/proto"
)
// confirmDays is how many distinct UTC days a cluster must have reported
// on before it counts as confirmed in the aggregated stats.
const confirmDays = 7
// confirmThresholds are the confirmation windows the dashboard lets the
// viewer pick between; confirmDays is the one everything else is built on.
var confirmThresholds = []int{1, 3, 7, 14, 30}
// activeDays is how recently a cluster must have reported to count as active.
const activeDays = 7
// HistorySample is one retained data point of a cluster's daily reports.
// Tags are kept short because thousands of samples end up in the state file.
type HistorySample struct {
Ts int64 `json:"ts"` // unix seconds the report was received
TotalDiskBytes uint64 `json:"disk"`
TotalVolumeCount int32 `json:"volumes"`
VolumeServerCount int32 `json:"servers"`
Version string `json:"ver,omitempty"` // empty in samples written before this was recorded
}
// appendHistory records the report as the cluster's sample for the day,
// replacing an earlier sample from the same UTC day. Callers must hold s.mu.
func (s *PrometheusStorage) appendHistory(data *proto.TelemetryData, receivedAt time.Time) {
sample := HistorySample{
Ts: receivedAt.Unix(),
TotalDiskBytes: data.TotalDiskBytes,
TotalVolumeCount: data.TotalVolumeCount,
VolumeServerCount: data.VolumeServerCount,
Version: data.Version,
}
h := s.histories[data.TopologyId]
if n := len(h); n > 0 && sameUTCDay(h[n-1].Ts, sample.Ts) {
h[n-1] = sample
} else {
h = append(h, sample)
}
s.histories[data.TopologyId] = h
}
// seriesHistories picks the clusters the fleet-wide series are built from: the
// confirmed ones. A cluster that reported for less than a week is usually a CI
// or test cluster, and those arrive faster than they age out, so counting them
// makes every fleet total climb forever. Falls back to
// all clusters while none is confirmed yet, so a fresh server still draws its
// charts. Callers must hold s.mu.
func (s *PrometheusStorage) seriesHistories() map[string][]HistorySample {
confirmed := make(map[string][]HistorySample, len(s.histories))
for id, history := range s.histories {
if len(history) >= confirmDays {
confirmed[id] = history
}
}
if len(confirmed) == 0 {
return s.histories
}
return confirmed
}
func sameUTCDay(a, b int64) bool {
ta, tb := time.Unix(a, 0).UTC(), time.Unix(b, 0).UTC()
return ta.Year() == tb.Year() && ta.YearDay() == tb.YearDay()
}
// dailySeries is the shared date axis of the fleet-wide time series: one slot
// per UTC day, ending today.
type dailySeries struct {
dates []string
dayOf map[string]int
}
// newDailySeries builds the axis of UTC days ending today, spanning `days` days
// but starting no earlier than the first day any cluster reported on: a fresh
// server is asked for more days than it has history for, and padding those days
// with zeros draws a climb out of nothing that never happened.
func newDailySeries(days int, histories map[string][]HistorySample) dailySeries {
today := utcDay(time.Now().Unix())
requested := today.AddDate(0, 0, 1-days)
// Samples are appended in receive order, so [0] is a cluster's oldest.
var start time.Time
for _, history := range histories {
if len(history) == 0 {
continue
}
if first := utcDay(history[0].Ts); start.IsZero() || first.Before(start) {
start = first
}
}
if start.IsZero() || start.Before(requested) {
start = requested
}
n := int(today.Sub(start)/(24*time.Hour)) + 1
d := dailySeries{
dates: make([]string, n),
dayOf: make(map[string]int, n),
}
for i := range d.dates {
d.dates[i] = start.AddDate(0, 0, i).Format("2006-01-02")
d.dayOf[d.dates[i]] = i
}
return d
}
func utcDay(ts int64) time.Time {
t := time.Unix(ts, 0).UTC()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
func diskBytes(s HistorySample) uint64 { return s.TotalDiskBytes }
func serverCount(s HistorySample) uint64 { return uint64(s.VolumeServerCount) }
func sampleVersion(s HistorySample) string { return s.Version }
// align lays one cluster's history onto the axis, picking `value` out of each
// sample. Clusters report roughly once a day at no fixed hour, so a day without
// a report carries the previous value forward rather than dropping to zero; a
// cluster that stopped reporting altogether ends at its last sample instead of
// holding capacity forever. Reports false when the cluster has nothing in range.
func align[T any](d dailySeries, history []HistorySample, activeSince int64, value func(HistorySample) T) ([]T, bool) {
out := make([]T, len(d.dates))
reported := make([]bool, len(d.dates))
first, last := -1, -1
for _, sample := range history {
i, ok := d.dayOf[time.Unix(sample.Ts, 0).UTC().Format("2006-01-02")]
if !ok {
continue
}
out[i], reported[i] = value(sample), true
if first < 0 {
first = i
}
last = i
if sample.Ts >= activeSince {
last = len(d.dates) - 1 // still reporting, so hold to the right edge
}
}
if first < 0 {
return nil, false
}
for i := first + 1; i <= last; i++ {
if !reported[i] {
out[i] = out[i-1]
}
}
return out, true
}
// GetHistory returns the cluster's samples from the last `days` days.
// The second return value reports whether the cluster is known at all.
func (s *PrometheusStorage) GetHistory(clusterId string, days int) ([]HistorySample, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
h, ok := s.histories[clusterId]
if !ok {
return nil, false
}
cutoff := time.Now().AddDate(0, 0, -days).Unix()
samples := make([]HistorySample, 0, len(h))
for _, sample := range h {
if sample.Ts >= cutoff {
samples = append(samples, sample)
}
}
return samples, true
}

View File

@@ -0,0 +1,85 @@
package storage
import (
"path/filepath"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/seaweedfs/seaweedfs/telemetry/proto"
)
func TestClusterHistory(t *testing.T) {
s := newPrometheusStorage(prometheus.NewRegistry())
report := &proto.TelemetryData{
TopologyId: "hist-cluster",
Version: "4.40",
Os: "linux/amd64",
VolumeServerCount: 3,
TotalDiskBytes: proto.MinDiskBytes + 1000,
TotalVolumeCount: 10,
}
if err := s.StoreTelemetry(report); err != nil {
t.Fatalf("store: %v", err)
}
// A second report on the same UTC day replaces the day's sample.
report.TotalDiskBytes = proto.MinDiskBytes + 2000
if err := s.StoreTelemetry(report); err != nil {
t.Fatalf("store: %v", err)
}
samples, ok := s.GetHistory("hist-cluster", 90)
if !ok {
t.Fatal("cluster missing from history")
}
if len(samples) != 1 {
t.Fatalf("got %d samples, want 1 (same-day replace)", len(samples))
}
if samples[0].TotalDiskBytes != proto.MinDiskBytes+2000 {
t.Errorf("same-day sample not replaced: got %d", samples[0].TotalDiskBytes)
}
// An older sample from a previous day is appended and survives the
// state-file round trip.
s.mu.Lock()
s.histories["hist-cluster"] = append([]HistorySample{{
Ts: time.Now().AddDate(0, 0, -5).Unix(),
TotalDiskBytes: 500,
}}, s.histories["hist-cluster"]...)
s.mu.Unlock()
path := filepath.Join(t.TempDir(), "state.json")
if err := s.SaveStateIfDirty(path); err != nil {
t.Fatalf("save: %v", err)
}
s.instances = make(map[string]*telemetryData)
s.histories = make(map[string][]HistorySample)
if _, err := s.LoadState(path); err != nil {
t.Fatalf("load: %v", err)
}
samples, ok = s.GetHistory("hist-cluster", 90)
if !ok || len(samples) != 2 {
t.Fatalf("after round trip: ok=%v samples=%d, want 2", ok, len(samples))
}
if samples[0].TotalDiskBytes != 500 || samples[1].TotalDiskBytes != proto.MinDiskBytes+2000 {
t.Errorf("samples corrupted after round trip: %+v", samples)
}
// GetHistory filters by the requested window.
samples, _ = s.GetHistory("hist-cluster", 3)
if len(samples) != 1 {
t.Errorf("window filter: got %d samples, want 1", len(samples))
}
// Unknown cluster reports !ok.
if _, ok := s.GetHistory("nope", 90); ok {
t.Error("unknown cluster reported ok")
}
// Cleanup drops the history together with the instance.
s.CleanupOldInstances(0)
if _, ok := s.GetHistory("hist-cluster", 90); ok {
t.Error("history survived instance cleanup")
}
}

View File

@@ -0,0 +1,163 @@
package storage
import (
"testing"
"github.com/prometheus/client_golang/prometheus"
)
// Every cluster counts towards every day it reported on, not just towards the
// one day it last reported on.
func TestGetMetricsSumsEachDay(t *testing.T) {
s := newPrometheusStorage(prometheus.NewRegistry())
// Reported every day of the window.
seedSamples(s, "daily", HistorySample{TotalDiskBytes: 300, VolumeServerCount: 3},
-9, -8, -7, -6, -5, -4, -3, -2, -1, 0)
// Stopped reporting past the active window: counts on its own days only.
seedSamples(s, "gone", HistorySample{TotalDiskBytes: 900, VolumeServerCount: 9},
-14, -13, -12, -11, -10, -9, -8)
metrics, err := s.GetMetrics(10)
if err != nil {
t.Fatal(err)
}
if got := metrics["dates"].([]string); len(got) != 10 {
t.Fatalf("dates = %v, want 10 days", got)
}
if got := metrics["disk_usage"].([]uint64); !equal(got,
[]uint64{1200, 1200, 300, 300, 300, 300, 300, 300, 300, 300}) {
t.Errorf("disk_usage = %v, want the fleet total per day", got)
}
if got := metrics["server_counts"].([]int64); !equalInt64(got,
[]int64{12, 12, 3, 3, 3, 3, 3, 3, 3, 3}) {
t.Errorf("server_counts = %v, want the fleet total per day", got)
}
}
// A cluster that skipped a day keeps its size on that day rather than dipping
// the fleet total to zero and back.
func TestGetMetricsCarriesSkippedDaysForward(t *testing.T) {
s := newPrometheusStorage(prometheus.NewRegistry())
seedSamples(s, "gappy", HistorySample{TotalDiskBytes: 500, VolumeServerCount: 5},
-8, -7, -6, -5, -4, -3, -1)
metrics, err := s.GetMetrics(4)
if err != nil {
t.Fatal(err)
}
if got := metrics["disk_usage"].([]uint64); !equal(got, []uint64{500, 500, 500, 500}) {
t.Errorf("disk_usage = %v, want the skipped days carried forward", got)
}
}
// The window starts at the oldest sample the server actually has, so a server
// with less history than the caller asked for does not report a fleet that grew
// out of nothing on its first day of data.
func TestGetMetricsWindowStartsAtOldestSample(t *testing.T) {
s := newPrometheusStorage(prometheus.NewRegistry())
seedSamples(s, "recent", HistorySample{TotalDiskBytes: 100, VolumeServerCount: 1},
-6, -5, -4, -3, -2, -1, 0)
metrics, err := s.GetMetrics(30)
if err != nil {
t.Fatal(err)
}
if got := metrics["dates"].([]string); len(got) != 7 {
t.Errorf("dates = %v, want the 7 days with history, not 30", got)
}
if got := metrics["disk_usage"].([]uint64); !equal(got, []uint64{100, 100, 100, 100, 100, 100, 100}) {
t.Errorf("disk_usage = %v, want no leading zero days", got)
}
// History reaching past the requested window still clips to the window.
seedSamples(s, "old", HistorySample{TotalDiskBytes: 50, VolumeServerCount: 1},
-45, -44, -43, -42, -41, -40, -39)
metrics, err = s.GetMetrics(10)
if err != nil {
t.Fatal(err)
}
if got := metrics["dates"].([]string); len(got) != 10 {
t.Errorf("dates = %v, want 10 days", got)
}
}
// /api/metrics is the fleet total that /api/cluster-sizes breaks down per
// cluster, so their last day has to add up to the same number.
func TestGetMetricsAgreesWithClusterSizes(t *testing.T) {
s := newPrometheusStorage(prometheus.NewRegistry())
seedSamples(s, "daily", HistorySample{TotalDiskBytes: 300, VolumeServerCount: 3},
-9, -8, -7, -6, -5, -4, -3, -2, -1, 0)
seedSamples(s, "lagging", HistorySample{TotalDiskBytes: 200, VolumeServerCount: 2},
-8, -7, -6, -5, -4, -3, -2)
seedSamples(s, "gone", HistorySample{TotalDiskBytes: 900, VolumeServerCount: 9},
-14, -13, -12, -11, -10, -9, -8)
metrics, err := s.GetMetrics(10)
if err != nil {
t.Fatal(err)
}
disk := metrics["disk_usage"].([]uint64)
sizes := s.GetClusterSizeSeries(10, 1) // limit forces the Other fold-in too
if got, want := disk[len(disk)-1], sizes.TotalDisk; got != want {
t.Errorf("metrics last day = %d, cluster sizes total = %d", got, want)
}
if len(disk) != len(sizes.Dates) {
t.Errorf("metrics has %d days, cluster sizes has %d", len(disk), len(sizes.Dates))
}
}
// Short-lived clusters -- a CI run, a docker-compose demo -- report once under a
// fresh raft topology id and never again. Held forward for the whole active
// window they would stack up into a fleet that grows every day, so they stay out
// of the totals until they are confirmed.
func TestGetMetricsExcludesUnconfirmedClusters(t *testing.T) {
s := newPrometheusStorage(prometheus.NewRegistry())
seedSamples(s, "real", HistorySample{TotalDiskBytes: 300, VolumeServerCount: 3},
-6, -5, -4, -3, -2, -1, 0)
for _, id := range []string{"ci-1", "ci-2", "ci-3"} {
seedSamples(s, id, HistorySample{TotalDiskBytes: 5, VolumeServerCount: 14}, -1)
}
metrics, err := s.GetMetrics(4)
if err != nil {
t.Fatal(err)
}
if got := metrics["server_counts"].([]int64); !equalInt64(got, []int64{3, 3, 3, 3}) {
t.Errorf("server_counts = %v, want the confirmed cluster alone", got)
}
if got := metrics["disk_usage"].([]uint64); !equal(got, []uint64{300, 300, 300, 300}) {
t.Errorf("disk_usage = %v, want the confirmed cluster alone", got)
}
}
// Until any cluster has a week of history the charts fall back to every
// cluster, so a fresh server doesn't serve empty series.
func TestGetMetricsFallsBackWhenNoneConfirmed(t *testing.T) {
s := newPrometheusStorage(prometheus.NewRegistry())
seedSamples(s, "new", HistorySample{TotalDiskBytes: 100, VolumeServerCount: 2}, 0)
metrics, err := s.GetMetrics(7)
if err != nil {
t.Fatal(err)
}
if got := metrics["server_counts"].([]int64); !equalInt64(got, []int64{2}) {
t.Errorf("server_counts = %v, want today's only cluster", got)
}
}
func equalInt64(a, b []int64) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@@ -0,0 +1,115 @@
package storage
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/seaweedfs/seaweedfs/telemetry/proto"
)
// persistedState is the on-disk snapshot of the in-memory instance map.
type persistedState struct {
Instances map[string]*telemetryData `json:"instances"`
Histories map[string][]HistorySample `json:"histories,omitempty"`
}
// LoadState restores the instance map and Prometheus gauges from a state file
// written by SaveStateIfDirty. A missing file is not an error. Original
// ReceivedAt timestamps are preserved so cleanup and the active-cluster
// windows stay correct across restarts. Clusters under proto.MinDiskBytes are
// dropped, so state written before the floor sheds them on the first restart.
func (s *PrometheusStorage) LoadState(path string) (int, error) {
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
var state persistedState
if err := json.Unmarshal(b, &state); err != nil {
return 0, fmt.Errorf("parse %s: %w", path, err)
}
s.mu.Lock()
defer s.mu.Unlock()
loaded := 0
for id, instance := range state.Instances {
if instance == nil || instance.TelemetryData == nil || instance.TelemetryData.TopologyId == "" {
continue
}
if instance.TelemetryData.TotalDiskBytes < proto.MinDiskBytes {
s.dirty = true // so the next save sheds it
continue
}
s.instances[id] = instance
s.setClusterMetrics(instance.TelemetryData)
loaded++
}
for id, h := range state.Histories {
instance, ok := s.instances[id]
if !ok || len(h) == 0 {
continue
}
// State written before versions were recorded carries none on its
// samples. The newest sample is the report the instance record itself
// came from, so that day's version is known and the version series can
// start there rather than a day after the upgrade.
if newest := len(h) - 1; h[newest].Version == "" {
h[newest].Version = instance.TelemetryData.Version
}
s.histories[id] = h
}
s.updateStats()
return loaded, nil
}
// SaveStateIfDirty writes the instance map to path if it changed since the
// last successful save. The write is atomic (temp file + rename).
func (s *PrometheusStorage) SaveStateIfDirty(path string) error {
s.mu.Lock()
if !s.dirty {
s.mu.Unlock()
return nil
}
b, err := json.Marshal(&persistedState{Instances: s.instances, Histories: s.histories})
if err != nil {
s.mu.Unlock()
return err
}
s.dirty = false
s.mu.Unlock()
if err := s.writeAtomically(path, b); err != nil {
s.mu.Lock()
s.dirty = true
s.mu.Unlock()
return err
}
return nil
}
func (s *PrometheusStorage) writeAtomically(path string, b []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*")
if err != nil {
return err
}
if _, err := tmp.Write(b); err != nil {
tmp.Close()
os.Remove(tmp.Name())
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmp.Name())
return err
}
return os.Rename(tmp.Name(), path)
}

View File

@@ -0,0 +1,109 @@
package storage
import (
"path/filepath"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/seaweedfs/seaweedfs/telemetry/proto"
)
func TestStateRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "data", "telemetry-state.json")
s := newPrometheusStorage(prometheus.NewRegistry())
if err := s.SaveStateIfDirty(path); err != nil {
t.Fatalf("clean save: %v", err)
}
if _, err := s.LoadState(path); err != nil {
t.Fatalf("load with no state file: %v", err)
}
report := &proto.TelemetryData{
TopologyId: "test-cluster-1",
Version: "4.40",
Os: "linux/amd64",
VolumeServerCount: 5,
TotalDiskBytes: 123 << 30,
TotalVolumeCount: 42,
FilerCount: 2,
BrokerCount: 1,
Timestamp: time.Now().Unix(),
}
if err := s.StoreTelemetry(report); err != nil {
t.Fatalf("store: %v", err)
}
receivedAt := s.instances[report.TopologyId].ReceivedAt
if err := s.SaveStateIfDirty(path); err != nil {
t.Fatalf("save: %v", err)
}
if s.dirty {
t.Fatal("dirty flag not cleared after save")
}
// Simulate a restart: wipe the in-memory map, then restore.
s.instances = make(map[string]*telemetryData)
n, err := s.LoadState(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if n != 1 {
t.Fatalf("loaded %d instances, want 1", n)
}
got, ok := s.instances[report.TopologyId]
if !ok {
t.Fatal("instance missing after load")
}
if !got.ReceivedAt.Equal(receivedAt) {
t.Errorf("ReceivedAt not preserved: got %v, want %v", got.ReceivedAt, receivedAt)
}
if got.TelemetryData.TotalDiskBytes != report.TotalDiskBytes ||
got.TelemetryData.Version != report.Version ||
got.TelemetryData.VolumeServerCount != report.VolumeServerCount {
t.Errorf("fields not preserved: got %+v", got.TelemetryData)
}
// Load must not mark state dirty.
if s.dirty {
t.Error("dirty flag set after load")
}
}
// State written before versions were recorded still knows the version of the
// report its newest sample came from: the instance record's.
func TestLoadStateFillsNewestSampleVersion(t *testing.T) {
path := filepath.Join(t.TempDir(), "telemetry-state.json")
s := newPrometheusStorage(prometheus.NewRegistry())
report := &proto.TelemetryData{TopologyId: "test-cluster-1", Version: "4.40", Os: "linux/amd64", TotalDiskBytes: proto.MinDiskBytes}
if err := s.StoreTelemetry(report); err != nil {
t.Fatalf("store: %v", err)
}
yesterday := time.Now().AddDate(0, 0, -1).Unix()
s.histories[report.TopologyId] = []HistorySample{
{Ts: yesterday, TotalDiskBytes: 10},
{Ts: time.Now().Unix(), TotalDiskBytes: 20},
}
if err := s.SaveStateIfDirty(path); err != nil {
t.Fatalf("save: %v", err)
}
s = newPrometheusStorage(prometheus.NewRegistry())
if _, err := s.LoadState(path); err != nil {
t.Fatalf("load: %v", err)
}
loaded := s.histories[report.TopologyId]
if len(loaded) != 2 {
t.Fatalf("history = %+v, want 2 samples", loaded)
}
if loaded[1].Version != report.Version {
t.Errorf("newest sample version = %q, want %q", loaded[1].Version, report.Version)
}
if loaded[0].Version != "" {
t.Errorf("older sample version = %q, want it left unknown", loaded[0].Version)
}
}

View File

@@ -0,0 +1,332 @@
package storage
import (
"fmt"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/seaweedfs/seaweedfs/telemetry/proto"
)
type PrometheusStorage struct {
// Prometheus metrics
totalClusters prometheus.Gauge
activeClusters prometheus.Gauge
confirmedClusters prometheus.Gauge
volumeServerCount *prometheus.GaugeVec
totalDiskBytes *prometheus.GaugeVec
totalVolumeCount *prometheus.GaugeVec
filerCount *prometheus.GaugeVec
brokerCount *prometheus.GaugeVec
clusterInfo *prometheus.GaugeVec
telemetryReceived prometheus.Counter
reportsSkipped prometheus.Counter
// In-memory storage for API endpoints (if needed)
mu sync.RWMutex
instances map[string]*telemetryData
histories map[string][]HistorySample
stats map[string]interface{}
dirty bool // instances changed since the last successful state save
}
// telemetryData is an internal struct that includes the received timestamp
type telemetryData struct {
*proto.TelemetryData
ReceivedAt time.Time `json:"received_at"`
}
func NewPrometheusStorage() *PrometheusStorage {
return newPrometheusStorage(prometheus.DefaultRegisterer)
}
func newPrometheusStorage(reg prometheus.Registerer) *PrometheusStorage {
promauto := promauto.With(reg)
return &PrometheusStorage{
totalClusters: promauto.NewGauge(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_total_clusters",
Help: "Total number of unique SeaweedFS clusters (last 30 days)",
}),
activeClusters: promauto.NewGauge(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_active_clusters",
Help: "Number of active SeaweedFS clusters (last 7 days)",
}),
confirmedClusters: promauto.NewGauge(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_confirmed_clusters",
Help: "Active clusters seen on at least 7 distinct days (last 7 days)",
}),
volumeServerCount: promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_volume_servers",
Help: "Number of volume servers per cluster",
}, []string{"cluster_id"}),
totalDiskBytes: promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_disk_bytes",
Help: "Total disk usage in bytes per cluster",
}, []string{"cluster_id"}),
totalVolumeCount: promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_volume_count",
Help: "Total number of volumes per cluster",
}, []string{"cluster_id"}),
filerCount: promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_filer_count",
Help: "Number of filer servers per cluster",
}, []string{"cluster_id"}),
brokerCount: promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_broker_count",
Help: "Number of broker servers per cluster",
}, []string{"cluster_id"}),
clusterInfo: promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "seaweedfs_telemetry_cluster_info",
Help: "Cluster information (always 1, labels contain metadata)",
}, []string{"cluster_id", "version", "os"}),
telemetryReceived: promauto.NewCounter(prometheus.CounterOpts{
Name: "seaweedfs_telemetry_reports_received_total",
Help: "Total number of telemetry reports received",
}),
reportsSkipped: promauto.NewCounter(prometheus.CounterOpts{
Name: "seaweedfs_telemetry_reports_skipped_total",
Help: fmt.Sprintf("Reports not kept because the cluster stores less than %d GiB", proto.MinDiskBytes>>30),
}),
instances: make(map[string]*telemetryData),
histories: make(map[string][]HistorySample),
stats: make(map[string]interface{}),
}
}
func (s *PrometheusStorage) StoreTelemetry(data *proto.TelemetryData) error {
s.mu.Lock()
defer s.mu.Unlock()
s.telemetryReceived.Inc()
if data.TotalDiskBytes < proto.MinDiskBytes {
s.reportsSkipped.Inc()
return nil
}
// Drop the cluster_info series recorded under the previous label set when
// a cluster reports back with a different version or OS, so it is not
// counted under two versions at once.
if prev, ok := s.instances[data.TopologyId]; ok &&
(prev.TelemetryData.Version != data.Version || prev.TelemetryData.Os != data.Os) {
s.clusterInfo.Delete(infoLabels(prev.TelemetryData))
}
s.setClusterMetrics(data)
// Store in memory for API endpoints
receivedAt := time.Now().UTC()
s.instances[data.TopologyId] = &telemetryData{
TelemetryData: data,
ReceivedAt: receivedAt,
}
s.appendHistory(data, receivedAt)
s.dirty = true
// Update aggregated stats
s.updateStats()
return nil
}
// setClusterMetrics records a report's values on the Prometheus gauges.
// Value gauges are keyed by cluster_id only so a cluster's series continues
// across upgrades; version/os metadata lives on cluster_info (join with
// `* on(cluster_id) group_left(version, os)`). Callers must hold s.mu.
func (s *PrometheusStorage) setClusterMetrics(data *proto.TelemetryData) {
labels := prometheus.Labels{
"cluster_id": data.TopologyId,
}
s.volumeServerCount.With(labels).Set(float64(data.VolumeServerCount))
s.totalDiskBytes.With(labels).Set(float64(data.TotalDiskBytes))
s.totalVolumeCount.With(labels).Set(float64(data.TotalVolumeCount))
s.filerCount.With(labels).Set(float64(data.FilerCount))
s.brokerCount.With(labels).Set(float64(data.BrokerCount))
s.clusterInfo.With(infoLabels(data)).Set(1)
}
func (s *PrometheusStorage) GetStats() (map[string]interface{}, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// Return cached stats
result := make(map[string]interface{})
for k, v := range s.stats {
result[k] = v
}
return result, nil
}
func (s *PrometheusStorage) GetInstances(limit int) ([]*telemetryData, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var instances []*telemetryData
count := 0
for _, instance := range s.instances {
if count >= limit {
break
}
instances = append(instances, instance)
count++
}
return instances, nil
}
// GetMetrics returns fleet-wide daily totals across confirmed clusters for the
// last `days` days, in the parallel-array shape the dashboard charts expect.
// Totals come from the daily histories rather than from s.instances, which holds
// only each cluster's most recent report and so would credit every cluster to
// the single day it last reported on.
func (s *PrometheusStorage) GetMetrics(days int) (map[string]interface{}, error) {
s.mu.RLock()
defer s.mu.RUnlock()
histories := s.seriesHistories()
axis := newDailySeries(days, histories)
activeSince := time.Now().UTC().AddDate(0, 0, -activeDays).Unix()
diskUsage := make([]uint64, len(axis.dates))
serverCounts := make([]int64, len(axis.dates))
for _, history := range histories {
if disk, ok := align(axis, history, activeSince, diskBytes); ok {
for i, v := range disk {
diskUsage[i] += v
}
}
if servers, ok := align(axis, history, activeSince, serverCount); ok {
for i, v := range servers {
serverCounts[i] += int64(v)
}
}
}
return map[string]interface{}{
"dates": axis.dates,
"server_counts": serverCounts,
"disk_usage": diskUsage,
}, nil
}
func (s *PrometheusStorage) updateStats() {
now := time.Now()
last7Days := now.AddDate(0, 0, -activeDays)
last30Days := now.AddDate(0, 0, -30)
totalInstances := 0
activeInstances := 0
confirmedInstances := 0
confirmedByDays := make(map[int]int, len(confirmThresholds))
for _, threshold := range confirmThresholds {
confirmedByDays[threshold] = 0
}
versionsAll := make(map[string]int)
osAll := make(map[string]int)
versionsConfirmed := make(map[string]int)
osConfirmed := make(map[string]int)
for _, instance := range s.instances {
if instance.ReceivedAt.After(last30Days) {
totalInstances++
}
if instance.ReceivedAt.After(last7Days) {
activeInstances++
versionsAll[instance.TelemetryData.Version]++
osAll[instance.TelemetryData.Os]++
// A cluster is confirmed once seen on confirmDays distinct UTC
// days (histories hold one sample per day), so short-lived
// clusters can't skew the distributions below.
daysSeen := len(s.histories[instance.TelemetryData.TopologyId])
for _, threshold := range confirmThresholds {
if daysSeen >= threshold {
confirmedByDays[threshold]++
}
}
if daysSeen >= confirmDays {
confirmedInstances++
versionsConfirmed[instance.TelemetryData.Version]++
osConfirmed[instance.TelemetryData.Os]++
}
}
}
// Before any cluster has a week of history (fresh server with no
// prior state), fall back to all active clusters so the dashboard
// distributions aren't empty.
versions, osDistribution := versionsConfirmed, osConfirmed
if confirmedInstances == 0 {
versions, osDistribution = versionsAll, osAll
}
// Update Prometheus gauges
s.totalClusters.Set(float64(totalInstances))
s.activeClusters.Set(float64(activeInstances))
s.confirmedClusters.Set(float64(confirmedInstances))
// Update cached stats for API
s.stats = map[string]interface{}{
"total_instances": totalInstances,
"active_instances": activeInstances,
"confirmed_instances": confirmedInstances,
"confirmed_by_days": confirmedByDays,
"versions": versions,
"os_distribution": osDistribution,
}
}
// CleanupOldInstances removes instances older than the specified duration
func (s *PrometheusStorage) CleanupOldInstances(maxAge time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
cutoff := time.Now().Add(-maxAge)
for instanceID, instance := range s.instances {
if instance.ReceivedAt.Before(cutoff) {
delete(s.instances, instanceID)
s.deleteClusterMetrics(instance.TelemetryData)
s.dirty = true
}
}
for id, h := range s.histories {
if _, ok := s.instances[id]; !ok {
delete(s.histories, id)
s.dirty = true
continue
}
i := 0
for i < len(h) && time.Unix(h[i].Ts, 0).Before(cutoff) {
i++
}
if i > 0 {
s.histories[id] = append([]HistorySample(nil), h[i:]...)
s.dirty = true
}
}
s.updateStats()
}
// deleteClusterMetrics removes all gauges stored for the given report's
// cluster. Callers must hold s.mu.
func (s *PrometheusStorage) deleteClusterMetrics(data *proto.TelemetryData) {
labels := prometheus.Labels{
"cluster_id": data.TopologyId,
}
s.volumeServerCount.Delete(labels)
s.totalDiskBytes.Delete(labels)
s.totalVolumeCount.Delete(labels)
s.filerCount.Delete(labels)
s.brokerCount.Delete(labels)
s.clusterInfo.Delete(infoLabels(data))
}
// infoLabels is the full label set used by the cluster_info metric.
func infoLabels(data *proto.TelemetryData) prometheus.Labels {
return prometheus.Labels{
"cluster_id": data.TopologyId,
"version": data.Version,
"os": data.Os,
}
}

View File

@@ -0,0 +1,90 @@
package storage
import (
"sort"
"time"
)
// ClusterSeries is one cluster's daily disk usage and volume server count,
// aligned to the shared date axis of the enclosing ClusterSizeSeries.
type ClusterSeries struct {
ClusterId string `json:"cluster_id"`
Disk []uint64 `json:"disk"`
Servers []uint64 `json:"servers"`
}
// OtherSeries is the clusters beyond the caller's limit, summed per day so a
// stacked chart still adds up to the fleet total.
type OtherSeries struct {
Count int `json:"count"`
Disk []uint64 `json:"disk"`
Servers []uint64 `json:"servers"`
}
// ClusterSizeSeries is per-cluster disk usage and volume server count over
// time: one value per cluster per day, largest cluster first, ranked by disk on
// their most recent day. Both metrics share one ranking so a cluster keeps its
// place, and its colour, across the charts drawn from this.
type ClusterSizeSeries struct {
Dates []string `json:"dates"`
Clusters []ClusterSeries `json:"clusters"`
Other *OtherSeries `json:"other,omitempty"`
ClusterCount int `json:"cluster_count"`
TotalDisk uint64 `json:"total_disk"` // across all clusters on the last day
TotalServers uint64 `json:"total_servers"` // across all clusters on the last day
}
// GetClusterSizeSeries returns the last `days` days of per-cluster disk usage
// and volume server counts across confirmed clusters. Clusters beyond `limit`
// are folded into Other.
func (s *PrometheusStorage) GetClusterSizeSeries(days, limit int) ClusterSizeSeries {
s.mu.RLock()
defer s.mu.RUnlock()
histories := s.seriesHistories()
axis := newDailySeries(days, histories)
activeSince := time.Now().UTC().AddDate(0, 0, -activeDays).Unix()
last := len(axis.dates) - 1
series := ClusterSizeSeries{Dates: axis.dates}
for id, history := range histories {
disk, ok := align(axis, history, activeSince, diskBytes)
if !ok {
continue
}
servers, _ := align(axis, history, activeSince, serverCount)
series.Clusters = append(series.Clusters, ClusterSeries{ClusterId: id, Disk: disk, Servers: servers})
series.TotalDisk += disk[last]
series.TotalServers += servers[last]
}
series.ClusterCount = len(series.Clusters)
// Rank by the latest day so the stack reads largest-first at its right
// edge, tie-breaking on id to keep the order stable across refreshes.
sort.Slice(series.Clusters, func(i, j int) bool {
a, b := series.Clusters[i], series.Clusters[j]
if a.Disk[last] != b.Disk[last] {
return a.Disk[last] > b.Disk[last]
}
return a.ClusterId < b.ClusterId
})
if limit > 0 && len(series.Clusters) > limit {
other := OtherSeries{
Count: len(series.Clusters) - limit,
Disk: make([]uint64, len(axis.dates)),
Servers: make([]uint64, len(axis.dates)),
}
for _, c := range series.Clusters[limit:] {
for i, v := range c.Disk {
other.Disk[i] += v
}
for i, v := range c.Servers {
other.Servers[i] += v
}
}
series.Clusters = series.Clusters[:limit]
series.Other = &other
}
return series
}

View File

@@ -0,0 +1,157 @@
package storage
import (
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/seaweedfs/seaweedfs/telemetry/proto"
)
// seedSamples gives a cluster one sample per listed day offset (0 is today).
// The sample's Ts is filled in per day offset.
func seedSamples(s *PrometheusStorage, id string, sample HistorySample, dayOffsets ...int) {
s.mu.Lock()
defer s.mu.Unlock()
for _, offset := range dayOffsets {
sample.Ts = time.Now().AddDate(0, 0, offset).Unix()
s.histories[id] = append(s.histories[id], sample)
}
}
func TestClusterSizeSeries(t *testing.T) {
s := newPrometheusStorage(prometheus.NewRegistry())
// Reported every day of the window.
seedSamples(s, "daily", HistorySample{TotalDiskBytes: 300, VolumeServerCount: 3},
-9, -8, -7, -6, -5, -4, -3, -2, -1, 0)
// Stopped reporting two days ago: still active, so its size is held to
// the right edge instead of dropping out of the stack.
seedSamples(s, "lagging", HistorySample{TotalDiskBytes: 200, VolumeServerCount: 2},
-8, -7, -6, -5, -4, -3, -2)
// Stopped reporting past the active window: its own days only.
seedSamples(s, "gone", HistorySample{TotalDiskBytes: 900, VolumeServerCount: 9},
-14, -13, -12, -11, -10, -9, -8)
// One day of history only: unconfirmed, so it stays out of the stack.
seedSamples(s, "oneshot", HistorySample{TotalDiskBytes: 400, VolumeServerCount: 4}, -1)
series := s.GetClusterSizeSeries(10, 0)
if len(series.Dates) != 10 {
t.Fatalf("dates = %v, want 10 days", series.Dates)
}
if series.ClusterCount != 3 {
t.Fatalf("cluster_count = %d, want 3", series.ClusterCount)
}
byId := map[string]ClusterSeries{}
for _, c := range series.Clusters {
byId[c.ClusterId] = c
}
if got := byId["daily"].Disk; !equal(got, []uint64{300, 300, 300, 300, 300, 300, 300, 300, 300, 300}) {
t.Errorf("daily = %v, want 300 every day", got)
}
if got := byId["lagging"].Disk; !equal(got, []uint64{0, 200, 200, 200, 200, 200, 200, 200, 200, 200}) {
t.Errorf("lagging = %v, want its size carried to the right edge", got)
}
if got := byId["gone"].Disk; !equal(got, []uint64{900, 900, 0, 0, 0, 0, 0, 0, 0, 0}) {
t.Errorf("gone = %v, want no capacity after its last report", got)
}
if _, ok := byId["oneshot"]; ok {
t.Errorf("unconfirmed cluster in the stack: %+v", byId["oneshot"])
}
// Volume servers ride the same axis and the same hold-forward rule.
if got := byId["daily"].Servers; !equal(got, []uint64{3, 3, 3, 3, 3, 3, 3, 3, 3, 3}) {
t.Errorf("daily servers = %v, want 3 every day", got)
}
if got := byId["lagging"].Servers; !equal(got, []uint64{0, 2, 2, 2, 2, 2, 2, 2, 2, 2}) {
t.Errorf("lagging servers = %v, want carried to the right edge", got)
}
if got := byId["gone"].Servers; !equal(got, []uint64{9, 9, 0, 0, 0, 0, 0, 0, 0, 0}) {
t.Errorf("gone servers = %v, want none after its last report", got)
}
// The totals are the last day's stack height: daily + lagging, not gone.
if series.TotalDisk != 500 {
t.Errorf("total_disk = %d, want 500", series.TotalDisk)
}
if series.TotalServers != 5 {
t.Errorf("total_servers = %d, want 5", series.TotalServers)
}
// Largest on the last day comes first so the stack reads top-down.
if series.Clusters[0].ClusterId != "daily" {
t.Errorf("order = %s first, want daily", series.Clusters[0].ClusterId)
}
// Clusters past the limit are summed into "other", per day.
series = s.GetClusterSizeSeries(10, 1)
if len(series.Clusters) != 1 || series.Clusters[0].ClusterId != "daily" {
t.Fatalf("limited clusters = %+v, want just daily", series.Clusters)
}
if series.Other == nil || series.Other.Count != 2 {
t.Fatalf("other = %+v, want 2 clusters", series.Other)
}
if !equal(series.Other.Disk, []uint64{900, 1100, 200, 200, 200, 200, 200, 200, 200, 200}) {
t.Errorf("other = %v, want lagging+gone summed per day", series.Other.Disk)
}
if !equal(series.Other.Servers, []uint64{9, 11, 2, 2, 2, 2, 2, 2, 2, 2}) {
t.Errorf("other servers = %v, want lagging+gone summed per day", series.Other.Servers)
}
if series.ClusterCount != 3 || series.TotalDisk != 500 || series.TotalServers != 5 {
t.Errorf("limit changed totals: count=%d disk=%d servers=%d, want 3/500/5",
series.ClusterCount, series.TotalDisk, series.TotalServers)
}
}
// A report that lands after an earlier one on the same day replaces it, so the
// series shows one value per cluster per day.
func TestClusterSizeSeriesUsesLatestDailySample(t *testing.T) {
s := newPrometheusStorage(prometheus.NewRegistry())
data := &proto.TelemetryData{
TopologyId: "aaaaaaaa-0000-0000-0000-000000000001",
Version: "4.40",
Os: "linux/amd64",
TotalDiskBytes: proto.MinDiskBytes + 100,
VolumeServerCount: 4,
}
if err := s.StoreTelemetry(data); err != nil {
t.Fatal(err)
}
data.TotalDiskBytes = proto.MinDiskBytes + 700
data.VolumeServerCount = 6
if err := s.StoreTelemetry(data); err != nil {
t.Fatal(err)
}
// Nothing was reported before today, so the axis is today alone rather
// than the full 3 days padded out with zeros.
series := s.GetClusterSizeSeries(3, 0)
if len(series.Clusters) != 1 {
t.Fatalf("clusters = %+v, want 1", series.Clusters)
}
if today := time.Now().UTC().Format("2006-01-02"); len(series.Dates) != 1 || series.Dates[0] != today {
t.Errorf("dates = %v, want %s only", series.Dates, today)
}
if got := series.Clusters[0].Disk; !equal(got, []uint64{proto.MinDiskBytes + 700}) {
t.Errorf("disk = %v, want today's latest sample only", got)
}
if got := series.Clusters[0].Servers; !equal(got, []uint64{6}) {
t.Errorf("servers = %v, want today's latest sample only", got)
}
if series.TotalDisk != proto.MinDiskBytes+700 || series.TotalServers != 6 {
t.Errorf("totals = %d disk / %d servers, want %d/6", series.TotalDisk, series.TotalServers, proto.MinDiskBytes+700)
}
}
func equal(a, b []uint64) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@@ -0,0 +1,163 @@
package storage
import (
"sort"
"strconv"
"strings"
"time"
)
// VersionCounts is how many clusters ran one version per day, aligned to the
// shared date axis of the enclosing VersionSeries.
type VersionCounts struct {
Version string `json:"version"`
Clusters []uint64 `json:"clusters"`
}
// OtherVersions is the versions beyond the caller's limit, summed per day so a
// stacked chart still adds up to the cluster total.
type OtherVersions struct {
Count int `json:"count"`
Clusters []uint64 `json:"clusters"`
}
// VersionSeries is the version make-up of the fleet over time: one cluster
// count per version per day, oldest release first.
type VersionSeries struct {
Dates []string `json:"dates"`
Versions []VersionCounts `json:"versions"`
Other *OtherVersions `json:"other,omitempty"`
TotalClusters uint64 `json:"total_clusters"` // across all versions on the last day
}
// GetVersionSeries returns the last `days` days of per-version cluster counts
// across confirmed clusters. Versions beyond `limit` are folded into Other,
// keeping the ones with the most clusters on the last day.
func (s *PrometheusStorage) GetVersionSeries(days, limit int) VersionSeries {
s.mu.RLock()
defer s.mu.RUnlock()
histories := versionedHistories(s.seriesHistories())
axis := newDailySeries(days, histories)
activeSince := time.Now().UTC().AddDate(0, 0, -activeDays).Unix()
last := len(axis.dates) - 1
series := VersionSeries{Dates: axis.dates}
daily := make(map[string][]uint64)
for _, history := range histories {
versions, ok := align(axis, history, activeSince, sampleVersion)
if !ok {
continue
}
for i, v := range versions {
if v == "" {
continue // before the cluster's first day in the window
}
counts, ok := daily[v]
if !ok {
counts = make([]uint64, len(axis.dates))
daily[v] = counts
}
counts[i]++
}
}
for v, counts := range daily {
series.Versions = append(series.Versions, VersionCounts{Version: v, Clusters: counts})
series.TotalClusters += counts[last]
}
// Ordered by release rather than by size: the caller stacks them, and a
// stack whose order changes with the counts is unreadable over time.
sort.Slice(series.Versions, func(i, j int) bool {
return versionLess(series.Versions[i].Version, series.Versions[j].Version)
})
if limit > 0 && len(series.Versions) > limit {
// Old releases are a long tail of one-cluster bands; keep the versions
// most of the fleet is on now and sum the rest into one band.
ranked := append([]VersionCounts(nil), series.Versions...)
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].Clusters[last] != ranked[j].Clusters[last] {
return ranked[i].Clusters[last] > ranked[j].Clusters[last]
}
return versionLess(ranked[j].Version, ranked[i].Version)
})
other := OtherVersions{Count: len(ranked) - limit, Clusters: make([]uint64, len(axis.dates))}
for _, v := range ranked[limit:] {
for i, c := range v.Clusters {
other.Clusters[i] += c
}
}
kept := make(map[string]bool, limit)
for _, v := range ranked[:limit] {
kept[v.Version] = true
}
versions := series.Versions[:0]
for _, v := range series.Versions {
if kept[v.Version] {
versions = append(versions, v)
}
}
series.Versions = versions
series.Other = &other
}
return series
}
// versionedHistories drops the samples recorded before the reported version was
// kept in history, so the version series spans the days it actually knows a
// version for instead of climbing out of a run of blank days.
func versionedHistories(histories map[string][]HistorySample) map[string][]HistorySample {
out := make(map[string][]HistorySample, len(histories))
for id, history := range histories {
var kept []HistorySample
for _, sample := range history {
if sample.Version != "" {
kept = append(kept, sample)
}
}
if len(kept) > 0 {
out[id] = kept
}
}
return out
}
// versionLess orders release strings like "3.97" and "4.40" by their numeric
// components rather than lexically, which would sort "10.02" before "9.99". A
// build suffix sits next to the release it was built from. Anything that
// doesn't parse, such as the "unknown" a client with no version compiled in
// reports, sorts first.
func versionLess(a, b string) bool {
an, aSuffix, aok := versionParts(a)
bn, bSuffix, bok := versionParts(b)
if aok != bok {
return !aok
}
if !aok {
return a < b
}
for i := 0; i < len(an) && i < len(bn); i++ {
if an[i] != bn[i] {
return an[i] < bn[i]
}
}
if len(an) != len(bn) {
return len(an) < len(bn)
}
return aSuffix < bSuffix
}
func versionParts(v string) ([]int, string, bool) {
number, suffix, _ := strings.Cut(v, "-")
fields := strings.Split(number, ".")
parts := make([]int, 0, len(fields))
for _, field := range fields {
n, err := strconv.Atoi(field)
if err != nil {
return nil, "", false
}
parts = append(parts, n)
}
return parts, suffix, len(parts) > 0
}

View File

@@ -0,0 +1,109 @@
package storage
import (
"testing"
"github.com/prometheus/client_golang/prometheus"
)
func TestVersionSeries(t *testing.T) {
s := newPrometheusStorage(prometheus.NewRegistry())
// Upgraded mid-window: its band leaves the old version for the new one.
seedSamples(s, "upgraded", HistorySample{Version: "4.39"}, -6, -5, -4, -3)
seedSamples(s, "upgraded", HistorySample{Version: "4.40"}, -2, -1, 0)
// Reported every day on the same version.
seedSamples(s, "steady", HistorySample{Version: "4.40"}, -6, -5, -4, -3, -2, -1, 0)
// One day of history only: unconfirmed, so it stays out of the stack.
seedSamples(s, "oneshot", HistorySample{Version: "4.40"}, -1)
// Confirmed, but its samples predate versions being recorded.
seedSamples(s, "versionless", HistorySample{}, -13, -12, -11, -10, -9, -8, -7)
series := s.GetVersionSeries(10, 0)
// The versionless days are dropped before the axis is built, so the chart
// spans the days a version is known for instead of climbing out of blanks.
if len(series.Dates) != 7 {
t.Fatalf("dates = %v, want the 7 days with versions", series.Dates)
}
if len(series.Versions) != 2 ||
series.Versions[0].Version != "4.39" || series.Versions[1].Version != "4.40" {
t.Fatalf("versions = %+v, want 4.39 then 4.40", series.Versions)
}
if got := series.Versions[0].Clusters; !equal(got, []uint64{1, 1, 1, 1, 0, 0, 0}) {
t.Errorf("4.39 = %v, want the upgraded cluster's first four days", got)
}
if got := series.Versions[1].Clusters; !equal(got, []uint64{1, 1, 1, 1, 2, 2, 2}) {
t.Errorf("4.40 = %v, want steady plus upgraded from day 5", got)
}
if series.TotalClusters != 2 {
t.Errorf("total_clusters = %d, want 2", series.TotalClusters)
}
if series.Other != nil {
t.Errorf("other = %+v, want none without a limit", series.Other)
}
}
func TestVersionSeriesHoldsForwardAndLimits(t *testing.T) {
s := newPrometheusStorage(prometheus.NewRegistry())
// Stopped reporting past the active window: its own days only.
seedSamples(s, "gone", HistorySample{Version: "3.97"}, -14, -13, -12, -11, -10, -9, -8)
// Reported every day of the window.
seedSamples(s, "daily", HistorySample{Version: "4.40"}, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0)
// Stopped reporting two days ago: still active, so it holds its version
// to the right edge instead of dropping out of the stack.
seedSamples(s, "lagging", HistorySample{Version: "4.30"}, -8, -7, -6, -5, -4, -3, -2)
series := s.GetVersionSeries(10, 0)
if len(series.Dates) != 10 {
t.Fatalf("dates = %v, want 10 days", series.Dates)
}
byVersion := map[string][]uint64{}
for _, v := range series.Versions {
byVersion[v.Version] = v.Clusters
}
if got := byVersion["3.97"]; !equal(got, []uint64{1, 1, 0, 0, 0, 0, 0, 0, 0, 0}) {
t.Errorf("3.97 = %v, want nothing after its last report", got)
}
if got := byVersion["4.30"]; !equal(got, []uint64{0, 1, 1, 1, 1, 1, 1, 1, 1, 1}) {
t.Errorf("4.30 = %v, want carried to the right edge", got)
}
if got := byVersion["4.40"]; !equal(got, []uint64{1, 1, 1, 1, 1, 1, 1, 1, 1, 1}) {
t.Errorf("4.40 = %v, want 1 every day", got)
}
if series.TotalClusters != 2 {
t.Errorf("total_clusters = %d, want the 2 clusters still reporting", series.TotalClusters)
}
// Past the limit, the versions the fewest clusters are on are summed into
// one band; ties on the last day keep the newer version.
series = s.GetVersionSeries(10, 1)
if len(series.Versions) != 1 || series.Versions[0].Version != "4.40" {
t.Fatalf("limited versions = %+v, want just 4.40", series.Versions)
}
if series.Other == nil || series.Other.Count != 2 {
t.Fatalf("other = %+v, want 2 versions", series.Other)
}
if !equal(series.Other.Clusters, []uint64{1, 2, 1, 1, 1, 1, 1, 1, 1, 1}) {
t.Errorf("other = %v, want 3.97+4.30 summed per day", series.Other.Clusters)
}
if series.TotalClusters != 2 {
t.Errorf("limit changed total_clusters: %d, want 2", series.TotalClusters)
}
}
func TestVersionLess(t *testing.T) {
// Ordered oldest to newest; every pair must compare in this order.
ordered := []string{"unknown", "3.97", "4.02", "4.30", "4.30-enterprise", "9.99", "10.02"}
for i := 0; i < len(ordered); i++ {
for j := i + 1; j < len(ordered); j++ {
if !versionLess(ordered[i], ordered[j]) {
t.Errorf("versionLess(%q, %q) = false, want true", ordered[i], ordered[j])
}
if versionLess(ordered[j], ordered[i]) {
t.Errorf("versionLess(%q, %q) = true, want false", ordered[j], ordered[i])
}
}
}
}