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:
320
telemetry/DEPLOYMENT.md
Normal file
320
telemetry/DEPLOYMENT.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# SeaweedFS Telemetry Server Deployment
|
||||
|
||||
This document describes how to deploy the SeaweedFS telemetry server to a remote server using GitHub Actions, or via Docker.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A remote Linux server with:
|
||||
- SSH access
|
||||
- systemd (for service management)
|
||||
- Optional: Prometheus and Grafana (for monitoring)
|
||||
|
||||
2. GitHub repository secrets configured (see [Setup GitHub Secrets](#setup-github-secrets) below):
|
||||
- `TELEMETRY_SSH_PRIVATE_KEY`: SSH private key for accessing the remote server
|
||||
- `TELEMETRY_HOST`: Remote server hostname or IP address
|
||||
- `TELEMETRY_USER`: Username for SSH access
|
||||
|
||||
## Setup GitHub Secrets
|
||||
|
||||
Before using the deployment workflow, you need to configure the required secrets in your GitHub repository.
|
||||
|
||||
### Step 1: Generate SSH Key Pair
|
||||
|
||||
On your local machine, generate a new SSH key pair specifically for deployment:
|
||||
|
||||
```bash
|
||||
# Generate a new SSH key pair
|
||||
ssh-keygen -t ed25519 -C "seaweedfs-telemetry-deploy" -f ~/.ssh/seaweedfs_telemetry_deploy
|
||||
|
||||
# This creates two files:
|
||||
# ~/.ssh/seaweedfs_telemetry_deploy (private key)
|
||||
# ~/.ssh/seaweedfs_telemetry_deploy.pub (public key)
|
||||
```
|
||||
|
||||
### Step 2: Configure Remote Server
|
||||
|
||||
Copy the public key to your remote server:
|
||||
|
||||
```bash
|
||||
# Copy public key to remote server
|
||||
ssh-copy-id -i ~/.ssh/seaweedfs_telemetry_deploy.pub user@your-server.com
|
||||
|
||||
# Or manually append to authorized_keys
|
||||
cat ~/.ssh/seaweedfs_telemetry_deploy.pub | ssh user@your-server.com "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
|
||||
```
|
||||
|
||||
Test the SSH connection:
|
||||
|
||||
```bash
|
||||
# Test SSH connection with the new key
|
||||
ssh -i ~/.ssh/seaweedfs_telemetry_deploy user@your-server.com "echo 'SSH connection successful'"
|
||||
```
|
||||
|
||||
### Step 3: Add Secrets to GitHub Repository
|
||||
|
||||
1. Go to your GitHub repository
|
||||
2. Click on **Settings** tab
|
||||
3. In the sidebar, click **Secrets and variables** → **Actions**
|
||||
4. Click **New repository secret** for each of the following:
|
||||
|
||||
#### TELEMETRY_SSH_PRIVATE_KEY
|
||||
|
||||
```bash
|
||||
# Display the private key content
|
||||
cat ~/.ssh/seaweedfs_telemetry_deploy
|
||||
```
|
||||
|
||||
- **Name**: `TELEMETRY_SSH_PRIVATE_KEY`
|
||||
- **Value**: Copy the entire private key content, including the `-----BEGIN OPENSSH PRIVATE KEY-----` and `-----END OPENSSH PRIVATE KEY-----` lines
|
||||
|
||||
#### TELEMETRY_HOST
|
||||
|
||||
- **Name**: `TELEMETRY_HOST`
|
||||
- **Value**: Your server's hostname or IP address (e.g., `telemetry.example.com` or `192.168.1.100`)
|
||||
|
||||
#### TELEMETRY_USER
|
||||
|
||||
- **Name**: `TELEMETRY_USER`
|
||||
- **Value**: The username on the remote server (e.g., `ubuntu`, `deploy`, or your username)
|
||||
|
||||
### Step 4: Verify Configuration
|
||||
|
||||
Create a simple test workflow or manually trigger the deployment to verify the secrets are working correctly.
|
||||
|
||||
### Security Best Practices
|
||||
|
||||
1. **Dedicated SSH Key**: Use a separate SSH key only for deployment
|
||||
2. **Limited Permissions**: Create a dedicated user on the remote server with minimal required permissions
|
||||
3. **Key Rotation**: Regularly rotate SSH keys
|
||||
4. **Server Access**: Restrict SSH access to specific IP ranges if possible
|
||||
|
||||
### Example Server Setup
|
||||
|
||||
If you're setting up a new server, here's a basic configuration:
|
||||
|
||||
```bash
|
||||
# On the remote server, create a dedicated user for deployment
|
||||
sudo useradd -m -s /bin/bash seaweedfs-deploy
|
||||
sudo usermod -aG sudo seaweedfs-deploy # Only if sudo access is needed
|
||||
|
||||
# Switch to the deployment user
|
||||
sudo su - seaweedfs-deploy
|
||||
|
||||
# Create SSH directory
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
|
||||
# Add your public key (paste the content of seaweedfs_telemetry_deploy.pub)
|
||||
nano ~/.ssh/authorized_keys
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### SSH Connection Issues
|
||||
|
||||
```bash
|
||||
# Test SSH connection manually
|
||||
ssh -i ~/.ssh/seaweedfs_telemetry_deploy -v user@your-server.com
|
||||
|
||||
# Check SSH key permissions
|
||||
ls -la ~/.ssh/seaweedfs_telemetry_deploy*
|
||||
# Should show: -rw------- for private key, -rw-r--r-- for public key
|
||||
```
|
||||
|
||||
#### GitHub Actions Fails
|
||||
|
||||
1. **Check secrets**: Ensure all three secrets are properly set in GitHub
|
||||
2. **Verify SSH key**: Make sure the entire private key (including headers/footers) is copied
|
||||
3. **Test connectivity**: Manually SSH to the server from your local machine
|
||||
4. **Check user permissions**: Ensure the remote user has necessary permissions
|
||||
|
||||
## GitHub Actions Workflow
|
||||
|
||||
The deployment workflow (`.github/workflows/deploy_telemetry.yml`) provides two main operations:
|
||||
|
||||
### 1. First-time Setup
|
||||
|
||||
Run this once to set up the remote server:
|
||||
|
||||
1. Go to GitHub Actions in your repository
|
||||
2. Select "Deploy Telemetry Server" workflow
|
||||
3. Click "Run workflow"
|
||||
4. Check "Run first-time server setup"
|
||||
5. Click "Run workflow"
|
||||
|
||||
This will:
|
||||
- Create necessary directories on the remote server
|
||||
- Set up systemd service configuration
|
||||
- Configure log rotation
|
||||
- Upload Grafana dashboard and Prometheus configuration
|
||||
- Enable the telemetry service (but not start it yet)
|
||||
|
||||
**Note**: The setup only prepares the infrastructure. You need to run a deployment afterward to install and start the telemetry server.
|
||||
|
||||
|
||||
### 2. Deploy Updates
|
||||
|
||||
To deploy updates, manually trigger deployment:
|
||||
1. Go to GitHub Actions in your repository
|
||||
2. Select "Deploy Telemetry Server" workflow
|
||||
3. Click "Run workflow"
|
||||
4. Check "Deploy telemetry server to remote server"
|
||||
5. Click "Run workflow"
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
You can build and run the telemetry server using Docker locally or on a remote host.
|
||||
|
||||
### Build
|
||||
|
||||
- Using Docker Compose (recommended):
|
||||
|
||||
```bash
|
||||
docker compose -f telemetry/docker-compose.yml build telemetry-server
|
||||
```
|
||||
|
||||
- Using docker build directly (from the repository root):
|
||||
|
||||
```bash
|
||||
docker build -t seaweedfs-telemetry \
|
||||
-f telemetry/server/Dockerfile \
|
||||
.
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
- With Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose -f telemetry/docker-compose.yml up -d telemetry-server
|
||||
```
|
||||
|
||||
- With docker run:
|
||||
|
||||
```bash
|
||||
docker run -d --name telemetry-server \
|
||||
-p 8080:8080 \
|
||||
seaweedfs-telemetry
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The container runs as a non-root user by default.
|
||||
- The image listens on port `8080` inside the container. Map it with `-p <host_port>:8080`.
|
||||
- You can pass flags to the server by appending them after the image name, e.g. `docker run -d -p 8353:8080 seaweedfs-telemetry -port=8353 -dashboard=false`.
|
||||
|
||||
## Server Directory Structure
|
||||
|
||||
After setup, the remote server will have:
|
||||
|
||||
```
|
||||
~/seaweedfs-telemetry/
|
||||
├── bin/
|
||||
│ └── telemetry-server # Binary executable
|
||||
├── logs/
|
||||
│ ├── telemetry.log # Application logs
|
||||
│ └── telemetry.error.log # Error logs
|
||||
├── data/ # State snapshots (telemetry-state.json), restored on restart
|
||||
├── grafana-dashboard.json # Grafana dashboard configuration
|
||||
└── prometheus.yml # Prometheus configuration
|
||||
```
|
||||
|
||||
## Service Management
|
||||
|
||||
The telemetry server runs as a systemd service:
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
sudo systemctl status telemetry.service
|
||||
|
||||
# View logs
|
||||
sudo journalctl -u telemetry.service -f
|
||||
|
||||
# Restart service
|
||||
sudo systemctl restart telemetry.service
|
||||
|
||||
# Stop/start service
|
||||
sudo systemctl stop telemetry.service
|
||||
sudo systemctl start telemetry.service
|
||||
```
|
||||
|
||||
## Accessing the Service
|
||||
|
||||
After deployment, the telemetry server will be available at (default ports shown; adjust if you override with `-port`):
|
||||
|
||||
- Docker default: `8080`
|
||||
- **Dashboard**: `http://your-server:8080`
|
||||
- **API**: `http://your-server:8080/api/*`
|
||||
- **Metrics**: `http://your-server:8080/metrics`
|
||||
- **Health Check**: `http://your-server:8080/health`
|
||||
|
||||
- Systemd example (if you configured a different port, e.g. `8353`):
|
||||
- **Dashboard**: `http://your-server:8353`
|
||||
- **API**: `http://your-server:8353/api/*`
|
||||
- **Metrics**: `http://your-server:8353/metrics`
|
||||
- **Health Check**: `http://your-server:8353/health`
|
||||
|
||||
## Optional: Prometheus and Grafana Integration
|
||||
|
||||
### Prometheus Setup
|
||||
|
||||
1. Install Prometheus on your server
|
||||
2. Update `/etc/prometheus/prometheus.yml` to include:
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: 'seaweedfs-telemetry'
|
||||
static_configs:
|
||||
- targets: ['localhost:8353']
|
||||
metrics_path: '/metrics'
|
||||
```
|
||||
|
||||
### Grafana Setup
|
||||
|
||||
1. Install Grafana on your server
|
||||
2. Import the dashboard from `~/seaweedfs-telemetry/grafana-dashboard.json`
|
||||
3. Configure Prometheus as a data source pointing to your Prometheus instance
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Deployment Fails
|
||||
|
||||
1. Check GitHub Actions logs for detailed error messages
|
||||
2. Verify SSH connectivity: `ssh user@host`
|
||||
3. Ensure all required secrets are configured in GitHub
|
||||
|
||||
### Service Won't Start
|
||||
|
||||
1. Check service logs: `sudo journalctl -u telemetry.service`
|
||||
2. Verify binary permissions: `ls -la ~/seaweedfs-telemetry/bin/`
|
||||
3. Test binary manually: `~/seaweedfs-telemetry/bin/telemetry-server -help`
|
||||
|
||||
### Port Conflicts
|
||||
|
||||
If port 8353 is already in use:
|
||||
|
||||
1. Edit the systemd service: `sudo systemctl edit telemetry.service`
|
||||
2. Add override configuration:
|
||||
```ini
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=/home/user/seaweedfs-telemetry/bin/telemetry-server -port=8354
|
||||
```
|
||||
3. Reload and restart: `sudo systemctl daemon-reload && sudo systemctl restart telemetry.service`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Firewall**: Consider restricting access to telemetry ports
|
||||
2. **SSH Keys**: Use dedicated SSH keys with minimal permissions
|
||||
3. **User Permissions**: Run the service as a non-privileged user
|
||||
4. **Network**: Consider running on internal networks only
|
||||
|
||||
## Monitoring
|
||||
|
||||
Monitor the deployment and service health:
|
||||
|
||||
- **GitHub Actions**: Check workflow runs for deployment status
|
||||
- **System Logs**: `sudo journalctl -u telemetry.service`
|
||||
- **Application Logs**: `tail -f ~/seaweedfs-telemetry/logs/telemetry.log`
|
||||
- **Health Endpoint**: `curl http://localhost:8353/health`
|
||||
- **Metrics**: `curl http://localhost:8353/metrics`
|
||||
379
telemetry/README.md
Normal file
379
telemetry/README.md
Normal file
@@ -0,0 +1,379 @@
|
||||
# SeaweedFS Telemetry System
|
||||
|
||||
A privacy-respecting telemetry system for SeaweedFS that collects cluster-level usage statistics and provides visualization through Prometheus and Grafana.
|
||||
|
||||
## Features
|
||||
|
||||
- **Privacy-First Design**: Uses in-memory cluster IDs (regenerated on restart), no personal data collection
|
||||
- **Prometheus Integration**: Native Prometheus metrics for monitoring and alerting
|
||||
- **Grafana Dashboards**: Pre-built dashboards for data visualization
|
||||
- **Protocol Buffers**: Efficient binary data transmission for optimal performance
|
||||
- **Opt-out**: On by default, turned off with a single flag
|
||||
- **Docker Compose**: Complete monitoring stack deployment
|
||||
- **Automatic Cleanup**: Configurable data retention policies
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
SeaweedFS Cluster → Telemetry Client → Telemetry Server → Prometheus → Grafana
|
||||
(protobuf) (metrics) (queries)
|
||||
```
|
||||
|
||||
## Data Transmission
|
||||
|
||||
The telemetry system uses **Protocol Buffers exclusively** for efficient binary data transmission:
|
||||
|
||||
- **Compact Format**: 30-50% smaller than JSON
|
||||
- **Fast Serialization**: Better performance than text-based formats
|
||||
- **Type Safety**: Strong typing with generated Go structs
|
||||
- **Schema Evolution**: Built-in versioning support
|
||||
|
||||
### Protobuf Schema
|
||||
|
||||
```protobuf
|
||||
message TelemetryData {
|
||||
string cluster_id = 1; // In-memory generated UUID
|
||||
string version = 2; // SeaweedFS version
|
||||
string os = 3; // Operating system
|
||||
// Field 4 reserved (was features)
|
||||
// Field 5 reserved (was deployment)
|
||||
int32 volume_server_count = 6; // Number of volume servers
|
||||
uint64 total_disk_bytes = 7; // Total disk usage
|
||||
int32 total_volume_count = 8; // Total volume count
|
||||
int32 filer_count = 9; // Number of filer servers
|
||||
int32 broker_count = 10; // Number of broker servers
|
||||
int64 timestamp = 11; // Collection timestamp
|
||||
}
|
||||
```
|
||||
|
||||
## Privacy Approach
|
||||
|
||||
- **No Personal Data**: No hostnames, IP addresses, or user information
|
||||
- **In-Memory IDs**: Cluster IDs are generated in-memory and change on restart
|
||||
- **Aggregated Data**: Only cluster-level statistics, no individual file/user data
|
||||
- **Opt-out Anytime**: `-telemetry=false` on the master stops all reporting
|
||||
- **Transparent**: Open source implementation, clear data collection policy
|
||||
|
||||
## Collected Data
|
||||
|
||||
| Field | Description | Example |
|
||||
|-------|-------------|---------|
|
||||
| `cluster_id` | In-memory UUID (changes on restart) | `a1b2c3d4-...` |
|
||||
| `version` | SeaweedFS version | `3.45` |
|
||||
| `os` | Operating system and architecture | `linux/amd64` |
|
||||
| `volume_server_count` | Number of volume servers | `5` |
|
||||
| `total_disk_bytes` | Total disk usage across cluster | `1073741824` |
|
||||
| `total_volume_count` | Total number of volumes | `120` |
|
||||
| `filer_count` | Number of filer servers | `2` |
|
||||
| `broker_count` | Number of broker servers | `1` |
|
||||
| `timestamp` | When data was collected | `1640995200` |
|
||||
|
||||
A master starts reporting once its cluster stores at least 10 GiB, and the server drops reports under that floor. Every fresh `weed server`, CI job and throwaway container mints its own cluster id, and at tens of thousands a day they were nearly all of the counted clusters and almost none of the bytes.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Deploy Telemetry Server
|
||||
|
||||
```bash
|
||||
# Clone and start the complete monitoring stack
|
||||
git clone https://github.com/seaweedfs/seaweedfs.git
|
||||
cd seaweedfs
|
||||
docker compose -f telemetry/docker-compose.yml up -d
|
||||
|
||||
# Or run the server directly
|
||||
cd telemetry/server
|
||||
go run . -port=8080 -dashboard=true
|
||||
```
|
||||
|
||||
### 2. Configure SeaweedFS
|
||||
|
||||
```bash
|
||||
# Reporting to telemetry.seaweedfs.com is on by default
|
||||
weed master
|
||||
|
||||
# Send to your own telemetry server instead
|
||||
weed master -telemetry.url=http://localhost:8080/api/collect
|
||||
|
||||
# Turn reporting off
|
||||
weed master -telemetry=false
|
||||
weed server -master.telemetry=false
|
||||
```
|
||||
|
||||
### 3. Access Dashboards
|
||||
|
||||
- **Telemetry Server**: http://localhost:8080
|
||||
- **Prometheus**: http://localhost:9090
|
||||
- **Grafana**: http://localhost:3000 (admin/admin)
|
||||
|
||||
## Configuration
|
||||
|
||||
### SeaweedFS Master/Server
|
||||
|
||||
```bash
|
||||
# Disable telemetry (enabled by default)
|
||||
-telemetry=false
|
||||
|
||||
# Set custom telemetry server URL (optional, defaults to telemetry.seaweedfs.com)
|
||||
-telemetry.url=http://your-telemetry-server:8080/api/collect
|
||||
```
|
||||
|
||||
In `weed server` and `weed mini` the flags are prefixed: `-master.telemetry=false` and `-master.telemetry.url=...`.
|
||||
|
||||
### Telemetry Server
|
||||
|
||||
```bash
|
||||
# Server configuration
|
||||
-port=8080 # Server port
|
||||
-dashboard=true # Enable built-in dashboard
|
||||
-cleanup=24h # Cleanup interval
|
||||
-max-age=2160h # Maximum data retention (90 days)
|
||||
-state-file=data/telemetry-state.json # Persist state across restarts (empty to disable)
|
||||
-state-save=1h # How often to save changed state
|
||||
|
||||
# Example
|
||||
./telemetry-server -port=8080 -dashboard=true -cleanup=24h -max-age=2160h
|
||||
```
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
The telemetry server exposes these Prometheus metrics:
|
||||
|
||||
### Cluster Metrics
|
||||
- `seaweedfs_telemetry_total_clusters`: Total unique clusters (30 days)
|
||||
- `seaweedfs_telemetry_active_clusters`: Active clusters (7 days)
|
||||
- `seaweedfs_telemetry_confirmed_clusters`: Active clusters seen on 7+ distinct days — one-shot reports don't count, and the version/OS distributions in `/api/stats` are computed over these
|
||||
|
||||
### Per-Cluster Metrics
|
||||
- `seaweedfs_telemetry_volume_servers{cluster_id}`: Volume servers per cluster
|
||||
- `seaweedfs_telemetry_disk_bytes{cluster_id}`: Disk usage per cluster
|
||||
- `seaweedfs_telemetry_volume_count{cluster_id}`: Volume count per cluster
|
||||
- `seaweedfs_telemetry_filer_count{cluster_id}`: Filer servers per cluster
|
||||
- `seaweedfs_telemetry_broker_count{cluster_id}`: Broker servers per cluster
|
||||
- `seaweedfs_telemetry_cluster_info{cluster_id, version, os}`: Cluster metadata
|
||||
|
||||
Value gauges are keyed by `cluster_id` only, so a cluster keeps one continuous
|
||||
series across upgrades. To slice values by version or OS, join with
|
||||
`cluster_info`, e.g.
|
||||
`seaweedfs_telemetry_disk_bytes * on(cluster_id) group_left(version, os) seaweedfs_telemetry_cluster_info`.
|
||||
|
||||
### Server Metrics
|
||||
- `seaweedfs_telemetry_reports_received_total`: Total telemetry reports received
|
||||
- `seaweedfs_telemetry_reports_skipped_total`: Reports dropped because the cluster stores less than 10 GiB
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Data Collection
|
||||
```bash
|
||||
# Submit telemetry data (protobuf only)
|
||||
POST /api/collect
|
||||
Content-Type: application/x-protobuf
|
||||
[TelemetryRequest protobuf data]
|
||||
```
|
||||
|
||||
### Statistics (JSON for dashboard/debugging)
|
||||
```bash
|
||||
# Get aggregated statistics
|
||||
GET /api/stats
|
||||
|
||||
# Get recent cluster instances
|
||||
GET /api/instances?limit=100
|
||||
|
||||
# Get metrics over time
|
||||
GET /api/metrics?days=30
|
||||
|
||||
# Get one cluster's daily usage history (disk bytes, volumes, volume servers)
|
||||
GET /api/history?cluster_id=<uuid>&days=90
|
||||
|
||||
# Get per-cluster disk usage and volume servers over time, largest first,
|
||||
# the rest summed as "other"
|
||||
GET /api/cluster-sizes?days=30&limit=20
|
||||
|
||||
# Get how many clusters ran each version over time, oldest version first,
|
||||
# the rest summed as "other"
|
||||
GET /api/versions?days=30&limit=8
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
```bash
|
||||
# Prometheus metrics
|
||||
GET /metrics
|
||||
```
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Complete Stack (Recommended)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
services:
|
||||
telemetry-server:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: telemetry/server/Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
command: ["-port=8080", "-dashboard=true", "-cleanup=24h"]
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
ports:
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
volumes:
|
||||
- ./grafana-provisioning:/etc/grafana/provisioning
|
||||
- ./grafana-dashboard.json:/var/lib/grafana/dashboards/seaweedfs.json
|
||||
```
|
||||
|
||||
```bash
|
||||
# Deploy the stack
|
||||
docker compose -f telemetry/docker-compose.yml up -d
|
||||
|
||||
# Scale telemetry server if needed
|
||||
docker compose -f telemetry/docker-compose.yml up -d --scale telemetry-server=3
|
||||
```
|
||||
|
||||
### Server Only
|
||||
|
||||
```bash
|
||||
# Build and run telemetry server (build from repo root to include all sources)
|
||||
docker build -t seaweedfs-telemetry -f telemetry/server/Dockerfile .
|
||||
docker run -p 8080:8080 seaweedfs-telemetry -port=8080 -dashboard=true
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Protocol Buffer Development
|
||||
|
||||
```bash
|
||||
# Generate protobuf code
|
||||
cd telemetry
|
||||
protoc --go_out=. --go_opt=paths=source_relative proto/telemetry.proto
|
||||
|
||||
# The generated code is already included in the repository
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
# Build telemetry server
|
||||
cd telemetry/server
|
||||
go build -o telemetry-server .
|
||||
|
||||
# Build SeaweedFS with telemetry support
|
||||
cd ../..
|
||||
go build -o weed ./weed
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Test telemetry server
|
||||
cd telemetry/server
|
||||
go test ./...
|
||||
|
||||
# Test protobuf communication (requires protobuf tools)
|
||||
# See telemetry client code for examples
|
||||
```
|
||||
|
||||
## Grafana Dashboard
|
||||
|
||||
The included Grafana dashboard provides:
|
||||
|
||||
- **Overview**: Total and active clusters, version distribution
|
||||
- **Resource Usage**: Volume servers and disk usage over time
|
||||
- **Infrastructure**: Operating system distribution and server counts
|
||||
- **Growth Trends**: Historical growth patterns
|
||||
|
||||
### Custom Queries
|
||||
|
||||
```promql
|
||||
# Total active clusters
|
||||
seaweedfs_telemetry_active_clusters
|
||||
|
||||
# Disk usage per cluster
|
||||
seaweedfs_telemetry_disk_bytes{cluster_id="<uuid>"}
|
||||
|
||||
# Disk usage by version (join with cluster_info for version/os)
|
||||
sum by (version) (seaweedfs_telemetry_disk_bytes * on(cluster_id) group_left(version) seaweedfs_telemetry_cluster_info)
|
||||
|
||||
# Volume servers by operating system
|
||||
sum by (os) (seaweedfs_telemetry_volume_servers * on(cluster_id) group_left(os) seaweedfs_telemetry_cluster_info)
|
||||
|
||||
# Broker servers across all clusters
|
||||
sum(seaweedfs_telemetry_broker_count)
|
||||
|
||||
# Growth rate (weekly)
|
||||
increase(seaweedfs_telemetry_total_clusters[7d])
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Network Security**: Use HTTPS in production environments
|
||||
- **Access Control**: Implement authentication for Grafana and Prometheus
|
||||
- **Data Retention**: Configure appropriate retention policies
|
||||
- **Monitoring**: Monitor the telemetry infrastructure itself
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**SeaweedFS not sending data:**
|
||||
```bash
|
||||
# Check telemetry configuration
|
||||
weed master -h | grep telemetry
|
||||
|
||||
# Verify connectivity
|
||||
curl -v http://your-telemetry-server:8080/api/collect
|
||||
```
|
||||
|
||||
**Server not receiving data:**
|
||||
```bash
|
||||
# Check server logs
|
||||
docker-compose logs telemetry-server
|
||||
|
||||
# Verify metrics endpoint
|
||||
curl http://localhost:8080/metrics
|
||||
```
|
||||
|
||||
**Prometheus not scraping:**
|
||||
```bash
|
||||
# Check Prometheus targets
|
||||
curl http://localhost:9090/api/v1/targets
|
||||
|
||||
# Verify configuration
|
||||
docker-compose logs prometheus
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
```bash
|
||||
# Enable verbose logging in SeaweedFS
|
||||
weed master -v=2 -telemetry=true
|
||||
|
||||
# Check telemetry server metrics
|
||||
curl http://localhost:8080/metrics | grep seaweedfs_telemetry
|
||||
|
||||
# Test data flow
|
||||
curl http://localhost:8080/api/stats
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests if applicable
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This telemetry system is part of SeaweedFS and follows the same Apache 2.0 license.
|
||||
55
telemetry/docker-compose.yml
Normal file
55
telemetry/docker-compose.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
services:
|
||||
telemetry-server:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: telemetry/server/Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
command: [
|
||||
"./telemetry-server",
|
||||
"-port=8080",
|
||||
"-dashboard=false", # Disable built-in dashboard, use Grafana
|
||||
"-log=true",
|
||||
"-cors=true"
|
||||
]
|
||||
networks:
|
||||
- telemetry
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
ports:
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--web.console.libraries=/etc/prometheus/console_libraries'
|
||||
- '--web.console.templates=/etc/prometheus/consoles'
|
||||
- '--storage.tsdb.retention.time=200h'
|
||||
- '--web.enable-lifecycle'
|
||||
networks:
|
||||
- telemetry
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./grafana-dashboard.json:/var/lib/grafana/dashboards/seaweedfs-telemetry.json
|
||||
- ./grafana-provisioning:/etc/grafana/provisioning
|
||||
networks:
|
||||
- telemetry
|
||||
|
||||
volumes:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
|
||||
networks:
|
||||
telemetry:
|
||||
driver: bridge
|
||||
734
telemetry/grafana-dashboard.json
Normal file
734
telemetry/grafana-dashboard.json
Normal file
@@ -0,0 +1,734 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"custom": {
|
||||
"align": "auto",
|
||||
"cellOptions": {
|
||||
"type": "auto"
|
||||
},
|
||||
"inspect": false
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"showHeader": true
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "seaweedfs_telemetry_total_clusters",
|
||||
"format": "time_series",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Total SeaweedFS Clusters",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"custom": {
|
||||
"align": "auto",
|
||||
"cellOptions": {
|
||||
"type": "auto"
|
||||
},
|
||||
"inspect": false
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"showHeader": true
|
||||
},
|
||||
"pluginVersion": "10.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "seaweedfs_telemetry_active_clusters",
|
||||
"format": "time_series",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Active Clusters (7 days)",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"vis": false
|
||||
}
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "visible",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
"values": false,
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": ""
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "count by (version) (seaweedfs_telemetry_cluster_info)",
|
||||
"format": "time_series",
|
||||
"legendFormat": "{{version}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "SeaweedFS Version Distribution",
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"vis": false
|
||||
}
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": {
|
||||
"displayMode": "visible",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
"values": false,
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": ""
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "count by (os) (seaweedfs_telemetry_cluster_info)",
|
||||
"format": "time_series",
|
||||
"legendFormat": "{{os}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Operating System Distribution",
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"vis": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 16
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(seaweedfs_telemetry_volume_servers)",
|
||||
"format": "time_series",
|
||||
"legendFormat": "Total Volume Servers",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Total Volume Servers Over Time",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"vis": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "bytes"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 24
|
||||
},
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(seaweedfs_telemetry_disk_bytes)",
|
||||
"format": "time_series",
|
||||
"legendFormat": "Total Disk Usage",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Total Disk Usage Over Time",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"vis": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 24
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(seaweedfs_telemetry_volume_count)",
|
||||
"format": "time_series",
|
||||
"legendFormat": "Total Volume Count",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Total Volume Count Over Time",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"vis": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 32
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(seaweedfs_telemetry_filer_count)",
|
||||
"format": "time_series",
|
||||
"legendFormat": "Total Filer Count",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Total Filer Servers Over Time",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"vis": false
|
||||
},
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 32
|
||||
},
|
||||
"id": 9,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(seaweedfs_telemetry_broker_count)",
|
||||
"format": "time_series",
|
||||
"legendFormat": "Total Broker Count",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Total Broker Servers Over Time",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "5m",
|
||||
"schemaVersion": 38,
|
||||
"style": "dark",
|
||||
"tags": [
|
||||
"seaweedfs",
|
||||
"telemetry"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-24h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "SeaweedFS Telemetry Dashboard",
|
||||
"uid": "seaweedfs-telemetry",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
12
telemetry/grafana-provisioning/dashboards/dashboards.yml
Normal file
12
telemetry/grafana-provisioning/dashboards/dashboards.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: 'seaweedfs'
|
||||
orgId: 1
|
||||
folder: ''
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 10
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: true
|
||||
15
telemetry/prometheus.yml
Normal file
15
telemetry/prometheus.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
rule_files:
|
||||
# - "first_rules.yml"
|
||||
# - "second_rules.yml"
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'seaweedfs-telemetry'
|
||||
static_configs:
|
||||
- targets: ['telemetry-server:8080']
|
||||
scrape_interval: 30s
|
||||
metrics_path: '/metrics'
|
||||
scrape_timeout: 10s
|
||||
7
telemetry/proto/floor.go
Normal file
7
telemetry/proto/floor.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package proto
|
||||
|
||||
// MinDiskBytes is what a cluster must store before its master reports it and
|
||||
// the server keeps the report: fresh `weed server` runs, CI jobs and throwaway
|
||||
// containers each mint their own cluster id, and they were most of the counted
|
||||
// clusters while holding almost none of the bytes.
|
||||
const MinDiskBytes uint64 = 10 << 30
|
||||
313
telemetry/proto/telemetry.pb.go
Normal file
313
telemetry/proto/telemetry.pb.go
Normal file
@@ -0,0 +1,313 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.6
|
||||
// protoc v6.33.1
|
||||
// source: telemetry/proto/telemetry.proto
|
||||
|
||||
package proto
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
// TelemetryData represents cluster-level telemetry information
|
||||
type TelemetryData struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Unique cluster identifier (generated in-memory)
|
||||
TopologyId string `protobuf:"bytes,1,opt,name=topology_id,json=topologyId,proto3" json:"topology_id,omitempty"`
|
||||
// SeaweedFS version
|
||||
Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"`
|
||||
// Operating system (e.g., "linux/amd64")
|
||||
Os string `protobuf:"bytes,3,opt,name=os,proto3" json:"os,omitempty"`
|
||||
// Number of volume servers in the cluster
|
||||
VolumeServerCount int32 `protobuf:"varint,6,opt,name=volume_server_count,json=volumeServerCount,proto3" json:"volume_server_count,omitempty"`
|
||||
// Total disk usage across all volume servers (in bytes)
|
||||
TotalDiskBytes uint64 `protobuf:"varint,7,opt,name=total_disk_bytes,json=totalDiskBytes,proto3" json:"total_disk_bytes,omitempty"`
|
||||
// Total number of volumes in the cluster
|
||||
TotalVolumeCount int32 `protobuf:"varint,8,opt,name=total_volume_count,json=totalVolumeCount,proto3" json:"total_volume_count,omitempty"`
|
||||
// Number of filer servers in the cluster
|
||||
FilerCount int32 `protobuf:"varint,9,opt,name=filer_count,json=filerCount,proto3" json:"filer_count,omitempty"`
|
||||
// Number of broker servers in the cluster
|
||||
BrokerCount int32 `protobuf:"varint,10,opt,name=broker_count,json=brokerCount,proto3" json:"broker_count,omitempty"`
|
||||
// Unix timestamp when the data was collected
|
||||
Timestamp int64 `protobuf:"varint,11,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *TelemetryData) Reset() {
|
||||
*x = TelemetryData{}
|
||||
mi := &file_telemetry_proto_telemetry_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *TelemetryData) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*TelemetryData) ProtoMessage() {}
|
||||
|
||||
func (x *TelemetryData) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_telemetry_proto_telemetry_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use TelemetryData.ProtoReflect.Descriptor instead.
|
||||
func (*TelemetryData) Descriptor() ([]byte, []int) {
|
||||
return file_telemetry_proto_telemetry_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *TelemetryData) GetTopologyId() string {
|
||||
if x != nil {
|
||||
return x.TopologyId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TelemetryData) GetVersion() string {
|
||||
if x != nil {
|
||||
return x.Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TelemetryData) GetOs() string {
|
||||
if x != nil {
|
||||
return x.Os
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TelemetryData) GetVolumeServerCount() int32 {
|
||||
if x != nil {
|
||||
return x.VolumeServerCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *TelemetryData) GetTotalDiskBytes() uint64 {
|
||||
if x != nil {
|
||||
return x.TotalDiskBytes
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *TelemetryData) GetTotalVolumeCount() int32 {
|
||||
if x != nil {
|
||||
return x.TotalVolumeCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *TelemetryData) GetFilerCount() int32 {
|
||||
if x != nil {
|
||||
return x.FilerCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *TelemetryData) GetBrokerCount() int32 {
|
||||
if x != nil {
|
||||
return x.BrokerCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *TelemetryData) GetTimestamp() int64 {
|
||||
if x != nil {
|
||||
return x.Timestamp
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// TelemetryRequest is sent from SeaweedFS clusters to the telemetry server
|
||||
type TelemetryRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Data *TelemetryData `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *TelemetryRequest) Reset() {
|
||||
*x = TelemetryRequest{}
|
||||
mi := &file_telemetry_proto_telemetry_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *TelemetryRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*TelemetryRequest) ProtoMessage() {}
|
||||
|
||||
func (x *TelemetryRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_telemetry_proto_telemetry_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use TelemetryRequest.ProtoReflect.Descriptor instead.
|
||||
func (*TelemetryRequest) Descriptor() ([]byte, []int) {
|
||||
return file_telemetry_proto_telemetry_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *TelemetryRequest) GetData() *TelemetryData {
|
||||
if x != nil {
|
||||
return x.Data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TelemetryResponse is returned by the telemetry server
|
||||
type TelemetryResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
|
||||
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *TelemetryResponse) Reset() {
|
||||
*x = TelemetryResponse{}
|
||||
mi := &file_telemetry_proto_telemetry_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *TelemetryResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*TelemetryResponse) ProtoMessage() {}
|
||||
|
||||
func (x *TelemetryResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_telemetry_proto_telemetry_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use TelemetryResponse.ProtoReflect.Descriptor instead.
|
||||
func (*TelemetryResponse) Descriptor() ([]byte, []int) {
|
||||
return file_telemetry_proto_telemetry_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *TelemetryResponse) GetSuccess() bool {
|
||||
if x != nil {
|
||||
return x.Success
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *TelemetryResponse) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_telemetry_proto_telemetry_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_telemetry_proto_telemetry_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x1ftelemetry/proto/telemetry.proto\x12\ttelemetry\"\xd0\x02\n" +
|
||||
"\rTelemetryData\x12\x1f\n" +
|
||||
"\vtopology_id\x18\x01 \x01(\tR\n" +
|
||||
"topologyId\x12\x18\n" +
|
||||
"\aversion\x18\x02 \x01(\tR\aversion\x12\x0e\n" +
|
||||
"\x02os\x18\x03 \x01(\tR\x02os\x12.\n" +
|
||||
"\x13volume_server_count\x18\x06 \x01(\x05R\x11volumeServerCount\x12(\n" +
|
||||
"\x10total_disk_bytes\x18\a \x01(\x04R\x0etotalDiskBytes\x12,\n" +
|
||||
"\x12total_volume_count\x18\b \x01(\x05R\x10totalVolumeCount\x12\x1f\n" +
|
||||
"\vfiler_count\x18\t \x01(\x05R\n" +
|
||||
"filerCount\x12!\n" +
|
||||
"\fbroker_count\x18\n" +
|
||||
" \x01(\x05R\vbrokerCount\x12\x1c\n" +
|
||||
"\ttimestamp\x18\v \x01(\x03R\ttimestampJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06\"@\n" +
|
||||
"\x10TelemetryRequest\x12,\n" +
|
||||
"\x04data\x18\x01 \x01(\v2\x18.telemetry.TelemetryDataR\x04data\"G\n" +
|
||||
"\x11TelemetryResponse\x12\x18\n" +
|
||||
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" +
|
||||
"\amessage\x18\x02 \x01(\tR\amessageB0Z.github.com/seaweedfs/seaweedfs/telemetry/protob\x06proto3"
|
||||
|
||||
var (
|
||||
file_telemetry_proto_telemetry_proto_rawDescOnce sync.Once
|
||||
file_telemetry_proto_telemetry_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_telemetry_proto_telemetry_proto_rawDescGZIP() []byte {
|
||||
file_telemetry_proto_telemetry_proto_rawDescOnce.Do(func() {
|
||||
file_telemetry_proto_telemetry_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_telemetry_proto_telemetry_proto_rawDesc), len(file_telemetry_proto_telemetry_proto_rawDesc)))
|
||||
})
|
||||
return file_telemetry_proto_telemetry_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_telemetry_proto_telemetry_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
|
||||
var file_telemetry_proto_telemetry_proto_goTypes = []any{
|
||||
(*TelemetryData)(nil), // 0: telemetry.TelemetryData
|
||||
(*TelemetryRequest)(nil), // 1: telemetry.TelemetryRequest
|
||||
(*TelemetryResponse)(nil), // 2: telemetry.TelemetryResponse
|
||||
}
|
||||
var file_telemetry_proto_telemetry_proto_depIdxs = []int32{
|
||||
0, // 0: telemetry.TelemetryRequest.data:type_name -> telemetry.TelemetryData
|
||||
1, // [1:1] is the sub-list for method output_type
|
||||
1, // [1:1] is the sub-list for method input_type
|
||||
1, // [1:1] is the sub-list for extension type_name
|
||||
1, // [1:1] is the sub-list for extension extendee
|
||||
0, // [0:1] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_telemetry_proto_telemetry_proto_init() }
|
||||
func file_telemetry_proto_telemetry_proto_init() {
|
||||
if File_telemetry_proto_telemetry_proto != nil {
|
||||
return
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_telemetry_proto_telemetry_proto_rawDesc), len(file_telemetry_proto_telemetry_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 3,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
GoTypes: file_telemetry_proto_telemetry_proto_goTypes,
|
||||
DependencyIndexes: file_telemetry_proto_telemetry_proto_depIdxs,
|
||||
MessageInfos: file_telemetry_proto_telemetry_proto_msgTypes,
|
||||
}.Build()
|
||||
File_telemetry_proto_telemetry_proto = out.File
|
||||
file_telemetry_proto_telemetry_proto_goTypes = nil
|
||||
file_telemetry_proto_telemetry_proto_depIdxs = nil
|
||||
}
|
||||
52
telemetry/proto/telemetry.proto
Normal file
52
telemetry/proto/telemetry.proto
Normal file
@@ -0,0 +1,52 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package telemetry;
|
||||
|
||||
option go_package = "github.com/seaweedfs/seaweedfs/telemetry/proto";
|
||||
|
||||
// TelemetryData represents cluster-level telemetry information
|
||||
message TelemetryData {
|
||||
// Unique cluster identifier (generated in-memory)
|
||||
string topology_id = 1;
|
||||
|
||||
// SeaweedFS version
|
||||
string version = 2;
|
||||
|
||||
// Operating system (e.g., "linux/amd64")
|
||||
string os = 3;
|
||||
|
||||
// Field 4 reserved (was features)
|
||||
reserved 4;
|
||||
|
||||
// Field 5 reserved (was deployment)
|
||||
reserved 5;
|
||||
|
||||
// Number of volume servers in the cluster
|
||||
int32 volume_server_count = 6;
|
||||
|
||||
// Total disk usage across all volume servers (in bytes)
|
||||
uint64 total_disk_bytes = 7;
|
||||
|
||||
// Total number of volumes in the cluster
|
||||
int32 total_volume_count = 8;
|
||||
|
||||
// Number of filer servers in the cluster
|
||||
int32 filer_count = 9;
|
||||
|
||||
// Number of broker servers in the cluster
|
||||
int32 broker_count = 10;
|
||||
|
||||
// Unix timestamp when the data was collected
|
||||
int64 timestamp = 11;
|
||||
}
|
||||
|
||||
// TelemetryRequest is sent from SeaweedFS clusters to the telemetry server
|
||||
message TelemetryRequest {
|
||||
TelemetryData data = 1;
|
||||
}
|
||||
|
||||
// TelemetryResponse is returned by the telemetry server
|
||||
message TelemetryResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
}
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
321
telemetry/test/integration.go
Normal file
321
telemetry/test/integration.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/telemetry/proto"
|
||||
"github.com/seaweedfs/seaweedfs/weed/telemetry"
|
||||
protobuf "google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
serverPort = "18080" // Use different port to avoid conflicts
|
||||
serverURL = "http://localhost:" + serverPort
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Starting SeaweedFS Telemetry Integration Test")
|
||||
|
||||
// Start telemetry server
|
||||
fmt.Println("Starting telemetry server...")
|
||||
serverCmd, err := startTelemetryServer()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to start telemetry server: %v", err)
|
||||
}
|
||||
defer stopServer(serverCmd)
|
||||
|
||||
// Wait for server to start
|
||||
if !waitForServer(serverURL+"/health", 15*time.Second) {
|
||||
log.Fatal("Telemetry server failed to start")
|
||||
}
|
||||
fmt.Println("Telemetry server started successfully")
|
||||
|
||||
// Test protobuf marshaling first
|
||||
fmt.Println("Testing protobuf marshaling...")
|
||||
if err := testProtobufMarshaling(); err != nil {
|
||||
log.Fatalf("Protobuf marshaling test failed: %v", err)
|
||||
}
|
||||
fmt.Println("Protobuf marshaling test passed")
|
||||
|
||||
// Test protobuf client
|
||||
fmt.Println("Testing protobuf telemetry client...")
|
||||
if err := testTelemetryClient(); err != nil {
|
||||
log.Fatalf("Telemetry client test failed: %v", err)
|
||||
}
|
||||
fmt.Println("Telemetry client test passed")
|
||||
|
||||
// Test server metrics endpoint
|
||||
fmt.Println("Testing Prometheus metrics endpoint...")
|
||||
if err := testMetricsEndpoint(); err != nil {
|
||||
log.Fatalf("Metrics endpoint test failed: %v", err)
|
||||
}
|
||||
fmt.Println("Metrics endpoint test passed")
|
||||
|
||||
// Test stats API
|
||||
fmt.Println("Testing stats API...")
|
||||
if err := testStatsAPI(); err != nil {
|
||||
log.Fatalf("Stats API test failed: %v", err)
|
||||
}
|
||||
fmt.Println("Stats API test passed")
|
||||
|
||||
// Test instances API
|
||||
fmt.Println("Testing instances API...")
|
||||
if err := testInstancesAPI(); err != nil {
|
||||
log.Fatalf("Instances API test failed: %v", err)
|
||||
}
|
||||
fmt.Println("Instances API test passed")
|
||||
|
||||
fmt.Println("All telemetry integration tests passed!")
|
||||
}
|
||||
|
||||
func startTelemetryServer() (*exec.Cmd, error) {
|
||||
// Get the directory where this test is running
|
||||
testDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
// Use pre-built binary if available (faster in CI), otherwise fall back to go run
|
||||
args := []string{
|
||||
"-port=" + serverPort,
|
||||
"-dashboard=false",
|
||||
"-cleanup=1m",
|
||||
"-max-age=1h",
|
||||
}
|
||||
|
||||
serverBin := filepath.Join(testDir, "telemetry", "server", "telemetry-server")
|
||||
var cmd *exec.Cmd
|
||||
if _, err := os.Stat(serverBin); err == nil {
|
||||
fmt.Printf("Using pre-built binary: %s\n", serverBin)
|
||||
cmd = exec.Command(serverBin, args...)
|
||||
} else {
|
||||
fmt.Println("No pre-built binary found, using go run")
|
||||
serverDir := filepath.Join(testDir, "telemetry", "server")
|
||||
cmd = exec.Command("go", append([]string{"run", "."}, args...)...)
|
||||
cmd.Dir = serverDir
|
||||
}
|
||||
|
||||
// Create log files for server output
|
||||
logFile, err := os.Create("telemetry-server-test.log")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create log file: %v", err)
|
||||
}
|
||||
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start server: %v", err)
|
||||
}
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func stopServer(cmd *exec.Cmd) {
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
cmd.Process.Signal(syscall.SIGTERM)
|
||||
cmd.Wait()
|
||||
|
||||
// Clean up log file
|
||||
os.Remove("telemetry-server-test.log")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForServer(url string, timeout time.Duration) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
fmt.Printf("Waiting for server at %s...\n", url)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
default:
|
||||
resp, err := http.Get(url)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return true
|
||||
}
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testProtobufMarshaling() error {
|
||||
// Test protobuf marshaling/unmarshaling
|
||||
testData := &proto.TelemetryData{
|
||||
TopologyId: "00000000-0000-4000-8000-000000012345",
|
||||
Version: "3.45",
|
||||
Os: "linux/amd64",
|
||||
VolumeServerCount: 2,
|
||||
TotalDiskBytes: 1000000,
|
||||
TotalVolumeCount: 10,
|
||||
FilerCount: 1,
|
||||
BrokerCount: 1,
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := protobuf.Marshal(testData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal protobuf: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf(" Protobuf size: %d bytes\n", len(data))
|
||||
|
||||
// Unmarshal
|
||||
testData2 := &proto.TelemetryData{}
|
||||
if err := protobuf.Unmarshal(data, testData2); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal protobuf: %v", err)
|
||||
}
|
||||
|
||||
// Verify data
|
||||
if testData2.TopologyId != testData.TopologyId {
|
||||
return fmt.Errorf("TopologyId mismatch: expected %s, got %s",
|
||||
testData.TopologyId, testData2.TopologyId)
|
||||
}
|
||||
|
||||
if testData2.VolumeServerCount != testData.VolumeServerCount {
|
||||
return fmt.Errorf("volume server count mismatch: expected %d, got %d",
|
||||
testData.VolumeServerCount, testData2.VolumeServerCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func testTelemetryClient() error {
|
||||
// Create telemetry client
|
||||
client := telemetry.NewClient(serverURL+"/api/collect", true)
|
||||
client.SetTopologyId("00000000-0000-4000-8000-000000054321")
|
||||
|
||||
// Create test data using protobuf format
|
||||
testData := &proto.TelemetryData{
|
||||
Version: "3.45",
|
||||
Os: "linux/amd64",
|
||||
VolumeServerCount: 3,
|
||||
TotalDiskBytes: 1073741824, // 1GB
|
||||
TotalVolumeCount: 50,
|
||||
FilerCount: 2,
|
||||
BrokerCount: 1,
|
||||
Timestamp: time.Now().Unix(),
|
||||
}
|
||||
|
||||
// Send telemetry data
|
||||
if err := client.SendTelemetry(testData); err != nil {
|
||||
return fmt.Errorf("failed to send telemetry: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf(" Sent telemetry for cluster: %s\n", client.GetInstanceID())
|
||||
|
||||
// Wait a bit for processing
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func testMetricsEndpoint() error {
|
||||
resp, err := http.Get(serverURL + "/metrics")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get metrics: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("metrics endpoint returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read response and check for expected metrics
|
||||
content, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read metrics response: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
expectedMetrics := []string{
|
||||
"seaweedfs_telemetry_total_clusters",
|
||||
"seaweedfs_telemetry_active_clusters",
|
||||
"seaweedfs_telemetry_reports_received_total",
|
||||
"seaweedfs_telemetry_volume_servers",
|
||||
"seaweedfs_telemetry_disk_bytes",
|
||||
"seaweedfs_telemetry_volume_count",
|
||||
"seaweedfs_telemetry_filer_count",
|
||||
"seaweedfs_telemetry_broker_count",
|
||||
}
|
||||
|
||||
for _, metric := range expectedMetrics {
|
||||
if !strings.Contains(contentStr, metric) {
|
||||
return fmt.Errorf("missing expected metric: %s", metric)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that we have at least one report received
|
||||
if !strings.Contains(contentStr, "seaweedfs_telemetry_reports_received_total 1") {
|
||||
fmt.Printf(" Warning: Expected at least 1 report received, metrics content:\n%s\n", contentStr)
|
||||
}
|
||||
|
||||
fmt.Printf(" Found %d expected metrics\n", len(expectedMetrics))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func testStatsAPI() error {
|
||||
resp, err := http.Get(serverURL + "/api/stats")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get stats: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("stats API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read and verify JSON response
|
||||
content, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read stats response: %v", err)
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
if !strings.Contains(contentStr, "total_instances") {
|
||||
return fmt.Errorf("stats response missing total_instances field")
|
||||
}
|
||||
|
||||
fmt.Printf(" Stats response: %s\n", contentStr)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func testInstancesAPI() error {
|
||||
resp, err := http.Get(serverURL + "/api/instances?limit=10")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get instances: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("instances API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read response
|
||||
content, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read instances response: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf(" Instances response length: %d bytes\n", len(content))
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user