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
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:
26
telemetry/server/Dockerfile
Normal file
26
telemetry/server/Dockerfile
Normal 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
97
telemetry/server/Makefile
Normal 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"
|
||||
231
telemetry/server/api/handlers.go
Normal file
231
telemetry/server/api/handlers.go
Normal 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,
|
||||
})
|
||||
}
|
||||
140
telemetry/server/api/handlers_test.go
Normal file
140
telemetry/server/api/handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
58
telemetry/server/api/validate.go
Normal file
58
telemetry/server/api/validate.go
Normal 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
|
||||
}
|
||||
591
telemetry/server/dashboard/dashboard.go
Normal file
591
telemetry/server/dashboard/dashboard.go
Normal 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
21
telemetry/server/go.mod
Normal 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
36
telemetry/server/go.sum
Normal 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
156
telemetry/server/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
87
telemetry/server/storage/confirmed_test.go
Normal file
87
telemetry/server/storage/confirmed_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
90
telemetry/server/storage/floor_test.go
Normal file
90
telemetry/server/storage/floor_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
175
telemetry/server/storage/history.go
Normal file
175
telemetry/server/storage/history.go
Normal 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
|
||||
}
|
||||
85
telemetry/server/storage/history_test.go
Normal file
85
telemetry/server/storage/history_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
163
telemetry/server/storage/metrics_test.go
Normal file
163
telemetry/server/storage/metrics_test.go
Normal 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
|
||||
}
|
||||
115
telemetry/server/storage/persistence.go
Normal file
115
telemetry/server/storage/persistence.go
Normal 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)
|
||||
}
|
||||
109
telemetry/server/storage/persistence_test.go
Normal file
109
telemetry/server/storage/persistence_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
332
telemetry/server/storage/prometheus.go
Normal file
332
telemetry/server/storage/prometheus.go
Normal 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,
|
||||
}
|
||||
}
|
||||
90
telemetry/server/storage/sizes.go
Normal file
90
telemetry/server/storage/sizes.go
Normal 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
|
||||
}
|
||||
157
telemetry/server/storage/sizes_test.go
Normal file
157
telemetry/server/storage/sizes_test.go
Normal 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
|
||||
}
|
||||
163
telemetry/server/storage/versions.go
Normal file
163
telemetry/server/storage/versions.go
Normal 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
|
||||
}
|
||||
109
telemetry/server/storage/versions_test.go
Normal file
109
telemetry/server/storage/versions_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user