mirror of
https://github.com/wearepixel/pulsewise-collector.git
synced 2026-11-14 14:01:50 +00:00
feat: full collector with macOS support, metrics, and one-click installer
- Collect CPU, memory, swap, disk, disk I/O, network (per-second rates), load average, system info, uptime, and top processes via gopsutil - Install script supports both Linux (systemd) and macOS (launchd) - Auto-detect OS and architecture for binary download - Accept TOKEN env var for non-interactive installs - Config at /etc/pulsewise-collector/config with simple KEY=VALUE format - Debug mode logs full response body - Hostname auto-detected if not set in config
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
go build -o pulsewise-collector .
|
||||||
|
echo "Built: pulsewise-collector"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Pulsewise Collector Configuration
|
||||||
|
# Copy this file to /etc/pulsewise-collector/config and fill in your values
|
||||||
|
|
||||||
|
TOKEN=your-token-here
|
||||||
|
|
||||||
|
# Optional — auto-detected from system hostname if omitted
|
||||||
|
# HOSTNAME=my-server
|
||||||
|
|
||||||
|
# Optional — defaults to https://pulsewise.app/api/collect
|
||||||
|
# URL=http://localhost:8000/api/collect
|
||||||
|
|
||||||
|
# Optional — logs full response body from the server
|
||||||
|
# DEBUG=true
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
module pulsewise-collector
|
||||||
|
|
||||||
|
go 1.24.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
golang.org/x/sys v0.20.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
|
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||||
|
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
Executable
+333
@@ -0,0 +1,333 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Pulsewise Collector — One-click installer
|
||||||
|
# Usage: curl -sSL https://pulsewise.app/install-collector.sh | TOKEN=your-token sudo -E bash
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
BOLD='\033[1m'
|
||||||
|
DIM='\033[2m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Detect OS and architecture
|
||||||
|
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
|
||||||
|
case "$OS" in
|
||||||
|
linux) OS_SLUG="linux" ;;
|
||||||
|
darwin) OS_SLUG="darwin" ;;
|
||||||
|
*)
|
||||||
|
echo -e "\033[0;31mUnsupported OS: $OS\033[0m"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64|amd64) ARCH_SLUG="amd64" ;;
|
||||||
|
aarch64|arm64) ARCH_SLUG="arm64" ;;
|
||||||
|
*)
|
||||||
|
echo -e "\033[0;31mUnsupported architecture: $ARCH\033[0m"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
BINARY_URL="https://pulsewise.app/collector/download/${OS_SLUG}-${ARCH_SLUG}"
|
||||||
|
BINARY_PATH="/usr/local/bin/pulsewise-collector"
|
||||||
|
CONFIG_DIR="/etc/pulsewise-collector"
|
||||||
|
CONFIG_FILE="${CONFIG_DIR}/config"
|
||||||
|
SERVICE_NAME="pulsewise-collector"
|
||||||
|
LAUNCHD_LABEL="app.pulsewise.collector"
|
||||||
|
LAUNCHD_PLIST="/Library/LaunchDaemons/${LAUNCHD_LABEL}.plist"
|
||||||
|
SYSTEMD_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Helpers
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
print_banner() {
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}${BOLD}"
|
||||||
|
echo " ┌─────────────────────────────────────┐"
|
||||||
|
echo " │ Pulsewise Collector Setup │"
|
||||||
|
echo " │ │"
|
||||||
|
echo " │ System monitoring made simple. │"
|
||||||
|
echo " └─────────────────────────────────────┘"
|
||||||
|
echo -e "${NC}"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
step() {
|
||||||
|
echo -e " ${CYAN}${BOLD}[$1/4]${NC} ${BOLD}$2${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
info() {
|
||||||
|
echo -e " $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
success() {
|
||||||
|
echo -e " ${GREEN}$1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
warn() {
|
||||||
|
echo -e " ${YELLOW}$1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo -e "\n ${RED}${BOLD}Error:${NC} $1\n"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt() {
|
||||||
|
local var_name="$1"
|
||||||
|
local prompt_text="$2"
|
||||||
|
local default_val="$3"
|
||||||
|
local secret="$4"
|
||||||
|
local value=""
|
||||||
|
|
||||||
|
if [ -n "$default_val" ]; then
|
||||||
|
prompt_text="${prompt_text} ${DIM}[${default_val}]${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ "$secret" = "true" ]; then
|
||||||
|
echo -ne " ${BOLD}${prompt_text}:${NC} "
|
||||||
|
read -rs value
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo -ne " ${BOLD}${prompt_text}:${NC} "
|
||||||
|
read -r value
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$value" ] && [ -n "$default_val" ]; then
|
||||||
|
value="$default_val"
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval "$var_name=\"$value\""
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_existing_service() {
|
||||||
|
if [ "$OS_SLUG" = "linux" ]; then
|
||||||
|
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||||
|
info "Stopping existing service..."
|
||||||
|
systemctl stop "$SERVICE_NAME"
|
||||||
|
fi
|
||||||
|
elif [ "$OS_SLUG" = "darwin" ]; then
|
||||||
|
if launchctl list "$LAUNCHD_LABEL" &>/dev/null; then
|
||||||
|
info "Stopping existing service..."
|
||||||
|
launchctl bootout system "$LAUNCHD_PLIST" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
register_service() {
|
||||||
|
if [ "$OS_SLUG" = "linux" ]; then
|
||||||
|
if ! command -v systemctl &> /dev/null; then
|
||||||
|
fail "systemd is required but not found on this system."
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$SYSTEMD_FILE" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Pulsewise Collector
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
ExecStart=$BINARY_PATH
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
systemctl daemon-reload
|
||||||
|
success "systemd service registered"
|
||||||
|
|
||||||
|
elif [ "$OS_SLUG" = "darwin" ]; then
|
||||||
|
cat > "$LAUNCHD_PLIST" <<EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>${LAUNCHD_LABEL}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>${BINARY_PATH}</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/var/log/pulsewise-collector.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/var/log/pulsewise-collector.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
success "launchd service registered"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
start_service() {
|
||||||
|
if [ "$OS_SLUG" = "linux" ]; then
|
||||||
|
systemctl enable --quiet "$SERVICE_NAME"
|
||||||
|
systemctl start "$SERVICE_NAME"
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||||
|
success "Service is running"
|
||||||
|
else
|
||||||
|
warn "Service may not have started. Check: journalctl -u $SERVICE_NAME -n 20"
|
||||||
|
fi
|
||||||
|
|
||||||
|
elif [ "$OS_SLUG" = "darwin" ]; then
|
||||||
|
launchctl bootstrap system "$LAUNCHD_PLIST"
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
if launchctl list "$LAUNCHD_LABEL" &>/dev/null; then
|
||||||
|
success "Service is running"
|
||||||
|
else
|
||||||
|
warn "Service may not have started. Check: /var/log/pulsewise-collector.log"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_summary() {
|
||||||
|
echo ""
|
||||||
|
echo -e " ${GREEN}${BOLD}Setup complete.${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${DIM}Hostname:${NC} $COLLECTOR_HOSTNAME"
|
||||||
|
echo -e " ${DIM}Config:${NC} $CONFIG_FILE"
|
||||||
|
echo -e " ${DIM}Binary:${NC} $BINARY_PATH"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$OS_SLUG" = "linux" ]; then
|
||||||
|
echo -e " ${DIM}Useful commands:${NC}"
|
||||||
|
echo -e " journalctl -u $SERVICE_NAME -f ${DIM}# follow logs${NC}"
|
||||||
|
echo -e " systemctl status $SERVICE_NAME ${DIM}# check status${NC}"
|
||||||
|
echo -e " systemctl restart $SERVICE_NAME ${DIM}# restart${NC}"
|
||||||
|
elif [ "$OS_SLUG" = "darwin" ]; then
|
||||||
|
echo -e " ${DIM}Useful commands:${NC}"
|
||||||
|
echo -e " tail -f /var/log/pulsewise-collector.log ${DIM}# follow logs${NC}"
|
||||||
|
echo -e " sudo launchctl kickstart -k system/${LAUNCHD_LABEL} ${DIM}# restart${NC}"
|
||||||
|
echo -e " sudo launchctl bootout system ${LAUNCHD_PLIST} ${DIM}# stop${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Pre-flight checks
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
print_banner
|
||||||
|
|
||||||
|
# Must be root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
fail "Please run as root:\n\n ${DIM}curl -sSL https://pulsewise.app/install-collector.sh | TOKEN=your-token sudo -E bash${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for existing installation
|
||||||
|
if [ -f "$BINARY_PATH" ] || [ -f "$CONFIG_FILE" ]; then
|
||||||
|
warn "An existing Pulsewise installation was detected."
|
||||||
|
echo ""
|
||||||
|
echo -ne " ${BOLD}Reinstall and overwrite? (y/N):${NC} "
|
||||||
|
read -r -n 1 REPLY
|
||||||
|
echo ""
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo -e "\n Installation cancelled.\n"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
stop_existing_service
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Onboarding
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo -e " ${DIM}Let's get your collector set up. This takes about 30 seconds.${NC}"
|
||||||
|
|
||||||
|
# Token — use env var if provided, otherwise prompt
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
prompt TOKEN "Pulsewise token" "" "true"
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
fail "A token is required. Find yours at https://pulsewise.app/settings/tokens"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
success "Token provided via environment"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Hostname — use env var if provided, otherwise prompt with system default
|
||||||
|
if [ -z "$COLLECTOR_HOSTNAME" ]; then
|
||||||
|
DEFAULT_HOSTNAME=$(hostname -s 2>/dev/null || hostname)
|
||||||
|
prompt COLLECTOR_HOSTNAME "Hostname" "$DEFAULT_HOSTNAME"
|
||||||
|
if [ -z "$COLLECTOR_HOSTNAME" ]; then
|
||||||
|
COLLECTOR_HOSTNAME="$DEFAULT_HOSTNAME"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
success "Hostname: $COLLECTOR_HOSTNAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Installation
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 1: Download binary
|
||||||
|
step 1 "Downloading collector..."
|
||||||
|
TEMP_BINARY=$(mktemp)
|
||||||
|
if command -v curl &> /dev/null; then
|
||||||
|
HTTP_CODE=$(curl -fsSL -o "$TEMP_BINARY" -w "%{http_code}" "$BINARY_URL" 2>/dev/null) || true
|
||||||
|
elif command -v wget &> /dev/null; then
|
||||||
|
wget -q -O "$TEMP_BINARY" "$BINARY_URL" 2>/dev/null && HTTP_CODE="200" || HTTP_CODE="000"
|
||||||
|
else
|
||||||
|
fail "Neither curl nor wget is available. Install one and try again."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -s "$TEMP_BINARY" ] || [ "$HTTP_CODE" != "200" ]; then
|
||||||
|
rm -f "$TEMP_BINARY"
|
||||||
|
fail "Download failed (HTTP $HTTP_CODE). Check your network and try again."
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod +x "$TEMP_BINARY"
|
||||||
|
mkdir -p "$(dirname "$BINARY_PATH")"
|
||||||
|
mv "$TEMP_BINARY" "$BINARY_PATH"
|
||||||
|
success "Installed to $BINARY_PATH"
|
||||||
|
|
||||||
|
# Step 2: Write configuration
|
||||||
|
step 2 "Writing configuration..."
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
cat > "$CONFIG_FILE" <<EOF
|
||||||
|
# Pulsewise Collector Configuration
|
||||||
|
# Docs: https://pulsewise.app/docs/collector
|
||||||
|
|
||||||
|
TOKEN=$TOKEN
|
||||||
|
HOSTNAME=$COLLECTOR_HOSTNAME
|
||||||
|
EOF
|
||||||
|
chmod 600 "$CONFIG_FILE"
|
||||||
|
success "Config saved to $CONFIG_FILE"
|
||||||
|
|
||||||
|
# Step 3: Register service
|
||||||
|
step 3 "Registering service..."
|
||||||
|
register_service
|
||||||
|
|
||||||
|
# Step 4: Start
|
||||||
|
step 4 "Starting collector..."
|
||||||
|
start_service
|
||||||
|
|
||||||
|
# Done
|
||||||
|
print_summary
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v3/cpu"
|
||||||
|
"github.com/shirou/gopsutil/v3/disk"
|
||||||
|
"github.com/shirou/gopsutil/v3/host"
|
||||||
|
"github.com/shirou/gopsutil/v3/load"
|
||||||
|
"github.com/shirou/gopsutil/v3/mem"
|
||||||
|
"github.com/shirou/gopsutil/v3/net"
|
||||||
|
"github.com/shirou/gopsutil/v3/process"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultURL = "https://pulsewise.app/api/collect"
|
||||||
|
collectionInterval = 30 * time.Second
|
||||||
|
configFilePath = "/etc/pulsewise-collector/config"
|
||||||
|
topProcessesCount = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Token string
|
||||||
|
Hostname string
|
||||||
|
URL string
|
||||||
|
Interval time.Duration
|
||||||
|
Debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Metrics struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Uptime uint64 `json:"uptime"`
|
||||||
|
System SystemInfo `json:"system"`
|
||||||
|
LoadAvg LoadMetrics `json:"load_avg"`
|
||||||
|
CPU CPUMetrics `json:"cpu"`
|
||||||
|
Memory MemoryMetrics `json:"memory"`
|
||||||
|
Swap SwapMetrics `json:"swap"`
|
||||||
|
Disks []DiskMetrics `json:"disks"`
|
||||||
|
DiskIO DiskIOMetrics `json:"disk_io"`
|
||||||
|
Network *NetworkMetrics `json:"network"`
|
||||||
|
Processes []ProcessInfo `json:"processes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemInfo struct {
|
||||||
|
OS string `json:"os"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
PlatformVersion string `json:"platform_version"`
|
||||||
|
KernelVersion string `json:"kernel_version"`
|
||||||
|
KernelArch string `json:"kernel_arch"`
|
||||||
|
Virtualization string `json:"virtualization"`
|
||||||
|
CPUModel string `json:"cpu_model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadMetrics struct {
|
||||||
|
Load1 float64 `json:"load1"`
|
||||||
|
Load5 float64 `json:"load5"`
|
||||||
|
Load15 float64 `json:"load15"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CPUMetrics struct {
|
||||||
|
Percent float64 `json:"percent"`
|
||||||
|
PerCore []float64 `json:"per_core"`
|
||||||
|
Count int `json:"core_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryMetrics struct {
|
||||||
|
Total uint64 `json:"total"`
|
||||||
|
Available uint64 `json:"available"`
|
||||||
|
Used uint64 `json:"used"`
|
||||||
|
UsedPercent float64 `json:"used_percent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SwapMetrics struct {
|
||||||
|
Total uint64 `json:"total"`
|
||||||
|
Used uint64 `json:"used"`
|
||||||
|
Free uint64 `json:"free"`
|
||||||
|
UsedPercent float64 `json:"used_percent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiskMetrics struct {
|
||||||
|
Mountpoint string `json:"mountpoint"`
|
||||||
|
Device string `json:"device"`
|
||||||
|
Fstype string `json:"fstype"`
|
||||||
|
Total uint64 `json:"total"`
|
||||||
|
Free uint64 `json:"free"`
|
||||||
|
Used uint64 `json:"used"`
|
||||||
|
UsedPercent float64 `json:"used_percent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiskIOMetrics struct {
|
||||||
|
ReadBytes uint64 `json:"read_bytes"`
|
||||||
|
WriteBytes uint64 `json:"write_bytes"`
|
||||||
|
ReadCount uint64 `json:"read_count"`
|
||||||
|
WriteCount uint64 `json:"write_count"`
|
||||||
|
IopsInProgress uint64 `json:"iops_in_progress"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NetworkMetrics struct {
|
||||||
|
BytesSentPerSec float64 `json:"bytes_sent_per_sec"`
|
||||||
|
BytesRecvPerSec float64 `json:"bytes_recv_per_sec"`
|
||||||
|
PacketsSentPerSec float64 `json:"packets_sent_per_sec"`
|
||||||
|
PacketsRecvPerSec float64 `json:"packets_recv_per_sec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type networkSnapshot struct {
|
||||||
|
BytesSent uint64
|
||||||
|
BytesRecv uint64
|
||||||
|
PacketsSent uint64
|
||||||
|
PacketsRecv uint64
|
||||||
|
Time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessInfo struct {
|
||||||
|
PID int32 `json:"pid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
|
MemPercent float32 `json:"mem_percent"`
|
||||||
|
CPUTime float64 `json:"cpu_time"`
|
||||||
|
MemoryBytes uint64 `json:"memory_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var prevNet *networkSnapshot
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
config := loadConfig()
|
||||||
|
|
||||||
|
log.Printf("Starting Pulsewise collector...")
|
||||||
|
log.Printf("Hostname: %s", config.Hostname)
|
||||||
|
log.Printf("Target URL: %s", config.URL)
|
||||||
|
log.Printf("Collection interval: %v", config.Interval)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(config.Interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
collectAndSend(config)
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
collectAndSend(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() *Config {
|
||||||
|
token, hostname, url, debug, err := loadConfigFromFile()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Config{
|
||||||
|
Token: token,
|
||||||
|
Hostname: hostname,
|
||||||
|
URL: url,
|
||||||
|
Interval: collectionInterval,
|
||||||
|
Debug: debug,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfigFromFile() (string, string, string, bool, error) {
|
||||||
|
configPath := configFilePath
|
||||||
|
|
||||||
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err == nil {
|
||||||
|
localConfigPath := filepath.Join(homeDir, ".config", "pulsewise-collector", "config")
|
||||||
|
if _, err := os.Stat(localConfigPath); err == nil {
|
||||||
|
configPath = localConfigPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", false, fmt.Errorf("config file not found at %s: %w", configPath, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var token, hostname, url string
|
||||||
|
var debug bool
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "TOKEN=") {
|
||||||
|
token = strings.TrimPrefix(line, "TOKEN=")
|
||||||
|
token = strings.Trim(token, `"'`)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "HOSTNAME=") {
|
||||||
|
hostname = strings.TrimPrefix(line, "HOSTNAME=")
|
||||||
|
hostname = strings.Trim(hostname, `"'`)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "URL=") {
|
||||||
|
url = strings.TrimPrefix(line, "URL=")
|
||||||
|
url = strings.Trim(url, `"'`)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "DEBUG=") {
|
||||||
|
val := strings.TrimPrefix(line, "DEBUG=")
|
||||||
|
val = strings.Trim(val, `"'`)
|
||||||
|
debug = val == "true" || val == "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return "", "", "", false, fmt.Errorf("error reading config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
return "", "", "", false, fmt.Errorf("TOKEN not found in config file")
|
||||||
|
}
|
||||||
|
if hostname == "" {
|
||||||
|
hostname, _ = os.Hostname()
|
||||||
|
}
|
||||||
|
|
||||||
|
if url == "" {
|
||||||
|
url = defaultURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, hostname, url, debug, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectAndSend(config *Config) {
|
||||||
|
metrics, err := collectMetrics(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error collecting metrics: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sendMetrics(config, metrics); err != nil {
|
||||||
|
log.Printf("Error sending metrics: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Successfully sent metrics at %s", time.Now().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectMetrics(config *Config) (*Metrics, error) {
|
||||||
|
metrics := &Metrics{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Token: config.Token,
|
||||||
|
Hostname: config.Hostname,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uptime
|
||||||
|
uptime, err := host.Uptime()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to get uptime: %v", err)
|
||||||
|
} else {
|
||||||
|
metrics.Uptime = uptime
|
||||||
|
}
|
||||||
|
|
||||||
|
// System info
|
||||||
|
hostInfo, err := host.Info()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to get host info: %v", err)
|
||||||
|
} else {
|
||||||
|
metrics.System = SystemInfo{
|
||||||
|
OS: hostInfo.OS,
|
||||||
|
Platform: hostInfo.Platform,
|
||||||
|
PlatformVersion: hostInfo.PlatformVersion,
|
||||||
|
KernelVersion: hostInfo.KernelVersion,
|
||||||
|
KernelArch: hostInfo.KernelArch,
|
||||||
|
Virtualization: hostInfo.VirtualizationSystem,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cpuInfo, err := cpu.Info()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to get CPU info: %v", err)
|
||||||
|
} else if len(cpuInfo) > 0 {
|
||||||
|
metrics.System.CPUModel = cpuInfo[0].ModelName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load average
|
||||||
|
loadAvg, err := load.Avg()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to get load average: %v", err)
|
||||||
|
} else {
|
||||||
|
metrics.LoadAvg = LoadMetrics{
|
||||||
|
Load1: loadAvg.Load1,
|
||||||
|
Load5: loadAvg.Load5,
|
||||||
|
Load15: loadAvg.Load15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPU
|
||||||
|
cpuPercent, err := cpu.Percent(time.Second, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get CPU percent: %w", err)
|
||||||
|
}
|
||||||
|
if len(cpuPercent) > 0 {
|
||||||
|
metrics.CPU.Percent = cpuPercent[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
cpuPercentPerCore, err := cpu.Percent(time.Second, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get per-core CPU percent: %w", err)
|
||||||
|
}
|
||||||
|
metrics.CPU.PerCore = cpuPercentPerCore
|
||||||
|
|
||||||
|
cpuCount, err := cpu.Counts(true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get CPU count: %w", err)
|
||||||
|
}
|
||||||
|
metrics.CPU.Count = cpuCount
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
memInfo, err := mem.VirtualMemory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get memory info: %w", err)
|
||||||
|
}
|
||||||
|
metrics.Memory = MemoryMetrics{
|
||||||
|
Total: memInfo.Total,
|
||||||
|
Available: memInfo.Available,
|
||||||
|
Used: memInfo.Used,
|
||||||
|
UsedPercent: memInfo.UsedPercent,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap
|
||||||
|
swapInfo, err := mem.SwapMemory()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to get swap info: %v", err)
|
||||||
|
} else {
|
||||||
|
metrics.Swap = SwapMetrics{
|
||||||
|
Total: swapInfo.Total,
|
||||||
|
Used: swapInfo.Used,
|
||||||
|
Free: swapInfo.Free,
|
||||||
|
UsedPercent: swapInfo.UsedPercent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk usage per mount point
|
||||||
|
partitions, err := disk.Partitions(false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get disk partitions: %w", err)
|
||||||
|
}
|
||||||
|
for _, p := range partitions {
|
||||||
|
usage, err := disk.Usage(p.Mountpoint)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if usage.Total == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
metrics.Disks = append(metrics.Disks, DiskMetrics{
|
||||||
|
Mountpoint: p.Mountpoint,
|
||||||
|
Device: p.Device,
|
||||||
|
Fstype: p.Fstype,
|
||||||
|
Total: usage.Total,
|
||||||
|
Free: usage.Free,
|
||||||
|
Used: usage.Used,
|
||||||
|
UsedPercent: usage.UsedPercent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk I/O (aggregate across all devices)
|
||||||
|
ioCounters, err := disk.IOCounters()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to get disk I/O: %v", err)
|
||||||
|
} else {
|
||||||
|
var totalRead, totalWrite, totalReadCount, totalWriteCount, totalIops uint64
|
||||||
|
for _, io := range ioCounters {
|
||||||
|
totalRead += io.ReadBytes
|
||||||
|
totalWrite += io.WriteBytes
|
||||||
|
totalReadCount += io.ReadCount
|
||||||
|
totalWriteCount += io.WriteCount
|
||||||
|
totalIops += io.IopsInProgress
|
||||||
|
}
|
||||||
|
metrics.DiskIO = DiskIOMetrics{
|
||||||
|
ReadBytes: totalRead,
|
||||||
|
WriteBytes: totalWrite,
|
||||||
|
ReadCount: totalReadCount,
|
||||||
|
WriteCount: totalWriteCount,
|
||||||
|
IopsInProgress: totalIops,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network (per-second rates)
|
||||||
|
netStats, err := net.IOCounters(false)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to get network stats: %v", err)
|
||||||
|
} else if len(netStats) > 0 {
|
||||||
|
var totalSent, totalRecv, totalPacketsSent, totalPacketsRecv uint64
|
||||||
|
for _, stat := range netStats {
|
||||||
|
totalSent += stat.BytesSent
|
||||||
|
totalRecv += stat.BytesRecv
|
||||||
|
totalPacketsSent += stat.PacketsSent
|
||||||
|
totalPacketsRecv += stat.PacketsRecv
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
current := &networkSnapshot{
|
||||||
|
BytesSent: totalSent,
|
||||||
|
BytesRecv: totalRecv,
|
||||||
|
PacketsSent: totalPacketsSent,
|
||||||
|
PacketsRecv: totalPacketsRecv,
|
||||||
|
Time: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if prevNet != nil &&
|
||||||
|
totalSent >= prevNet.BytesSent &&
|
||||||
|
totalRecv >= prevNet.BytesRecv {
|
||||||
|
elapsed := now.Sub(prevNet.Time).Seconds()
|
||||||
|
if elapsed > 0 {
|
||||||
|
metrics.Network = &NetworkMetrics{
|
||||||
|
BytesSentPerSec: float64(totalSent-prevNet.BytesSent) / elapsed,
|
||||||
|
BytesRecvPerSec: float64(totalRecv-prevNet.BytesRecv) / elapsed,
|
||||||
|
PacketsSentPerSec: float64(totalPacketsSent-prevNet.PacketsSent) / elapsed,
|
||||||
|
PacketsRecvPerSec: float64(totalPacketsRecv-prevNet.PacketsRecv) / elapsed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevNet = current
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top processes
|
||||||
|
processes, err := collectTopProcesses()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to collect process info: %v", err)
|
||||||
|
metrics.Processes = []ProcessInfo{}
|
||||||
|
} else {
|
||||||
|
metrics.Processes = processes
|
||||||
|
}
|
||||||
|
|
||||||
|
return metrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectTopProcesses() ([]ProcessInfo, error) {
|
||||||
|
pids, err := process.Pids()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get process list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type procData struct {
|
||||||
|
proc *process.Process
|
||||||
|
pid int32
|
||||||
|
name string
|
||||||
|
cmdline string
|
||||||
|
memPercent float32
|
||||||
|
memInfo *process.MemoryInfoStat
|
||||||
|
cpuTime float64
|
||||||
|
}
|
||||||
|
|
||||||
|
var processes []procData
|
||||||
|
|
||||||
|
for _, pid := range pids {
|
||||||
|
proc, err := process.NewProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = proc.CPUPercent()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name, err := proc.Name()
|
||||||
|
if err != nil {
|
||||||
|
name = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdline, err := proc.Cmdline()
|
||||||
|
if err != nil {
|
||||||
|
cmdline = name
|
||||||
|
}
|
||||||
|
if len(cmdline) > 200 {
|
||||||
|
cmdline = cmdline[:200] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
memPercent, err := proc.MemoryPercent()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
memInfo, err := proc.MemoryInfo()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cpuTimes, err := proc.Times()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cpuTime := cpuTimes.User + cpuTimes.System
|
||||||
|
|
||||||
|
processes = append(processes, procData{
|
||||||
|
proc: proc,
|
||||||
|
pid: pid,
|
||||||
|
name: name,
|
||||||
|
cmdline: cmdline,
|
||||||
|
memPercent: memPercent,
|
||||||
|
memInfo: memInfo,
|
||||||
|
cpuTime: cpuTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond * 200)
|
||||||
|
|
||||||
|
var processList []ProcessInfo
|
||||||
|
for _, p := range processes {
|
||||||
|
cpuPercent, err := p.proc.CPUPercent()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
processList = append(processList, ProcessInfo{
|
||||||
|
PID: p.pid,
|
||||||
|
Name: p.name,
|
||||||
|
Command: p.cmdline,
|
||||||
|
CPUPercent: cpuPercent,
|
||||||
|
MemPercent: p.memPercent,
|
||||||
|
CPUTime: p.cpuTime,
|
||||||
|
MemoryBytes: p.memInfo.RSS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by CPU usage (descending) and take top N
|
||||||
|
for i := 0; i < len(processList) && i < topProcessesCount; i++ {
|
||||||
|
maxIdx := i
|
||||||
|
for j := i + 1; j < len(processList); j++ {
|
||||||
|
if processList[j].CPUPercent > processList[maxIdx].CPUPercent {
|
||||||
|
maxIdx = j
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxIdx != i {
|
||||||
|
processList[i], processList[maxIdx] = processList[maxIdx], processList[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(processList) > topProcessesCount {
|
||||||
|
return processList[:topProcessesCount], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return processList, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMetrics(config *Config, metrics *Metrics) error {
|
||||||
|
jsonData, err := json.Marshal(metrics)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal metrics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", config.URL, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+config.Token)
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if config.Debug {
|
||||||
|
log.Printf("[DEBUG] Response %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("unexpected status code: %d — %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"timestamp": "2026-02-14T14:09:08.879637+10:00",
|
||||||
|
"token": "pw_tok_example_abc123",
|
||||||
|
"hostname": "web-1",
|
||||||
|
"uptime": 92582,
|
||||||
|
"system": {
|
||||||
|
"os": "linux",
|
||||||
|
"platform": "ubuntu",
|
||||||
|
"platform_version": "22.04",
|
||||||
|
"kernel_version": "6.1.0-18-amd64",
|
||||||
|
"kernel_arch": "x86_64",
|
||||||
|
"virtualization": "kvm",
|
||||||
|
"cpu_model": "Intel(R) Xeon(R) E-2288G CPU @ 3.70GHz"
|
||||||
|
},
|
||||||
|
"load_avg": {
|
||||||
|
"load1": 2.45,
|
||||||
|
"load5": 1.87,
|
||||||
|
"load15": 1.32
|
||||||
|
},
|
||||||
|
"cpu": {
|
||||||
|
"percent": 34.5,
|
||||||
|
"per_core": [
|
||||||
|
42.1,
|
||||||
|
28.3,
|
||||||
|
35.7,
|
||||||
|
31.9
|
||||||
|
],
|
||||||
|
"core_count": 4
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"total": 8589934592,
|
||||||
|
"available": 3221225472,
|
||||||
|
"used": 5368709120,
|
||||||
|
"used_percent": 62.5
|
||||||
|
},
|
||||||
|
"swap": {
|
||||||
|
"total": 2147483648,
|
||||||
|
"used": 524288000,
|
||||||
|
"free": 1623195648,
|
||||||
|
"used_percent": 24.41
|
||||||
|
},
|
||||||
|
"disks": [
|
||||||
|
{
|
||||||
|
"mountpoint": "/",
|
||||||
|
"device": "/dev/sda1",
|
||||||
|
"fstype": "ext4",
|
||||||
|
"total": 107374182400,
|
||||||
|
"free": 64424509440,
|
||||||
|
"used": 42949672960,
|
||||||
|
"used_percent": 40.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mountpoint": "/var",
|
||||||
|
"device": "/dev/sda2",
|
||||||
|
"fstype": "ext4",
|
||||||
|
"total": 53687091200,
|
||||||
|
"free": 21474836480,
|
||||||
|
"used": 32212254720,
|
||||||
|
"used_percent": 60.0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"disk_io": {
|
||||||
|
"read_bytes": 391543873536,
|
||||||
|
"write_bytes": 428321370112,
|
||||||
|
"read_count": 22743663,
|
||||||
|
"write_count": 19895365,
|
||||||
|
"iops_in_progress": 0
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"bytes_sent_per_sec": 31200.5,
|
||||||
|
"bytes_recv_per_sec": 52400.8,
|
||||||
|
"packets_sent_per_sec": 95.3,
|
||||||
|
"packets_recv_per_sec": 140.7
|
||||||
|
},
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"pid": 1234,
|
||||||
|
"name": "nginx",
|
||||||
|
"command": "nginx: worker process",
|
||||||
|
"cpu_percent": 12.5,
|
||||||
|
"mem_percent": 3.2,
|
||||||
|
"cpu_time": 4567.89,
|
||||||
|
"memory_bytes": 67108864
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pid": 5678,
|
||||||
|
"name": "postgres",
|
||||||
|
"command": "/usr/lib/postgresql/15/bin/postgres -D /var/lib/postgresql/15/main",
|
||||||
|
"cpu_percent": 8.1,
|
||||||
|
"mem_percent": 6.7,
|
||||||
|
"cpu_time": 12345.67,
|
||||||
|
"memory_bytes": 268435456
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pid": 9012,
|
||||||
|
"name": "php-fpm",
|
||||||
|
"command": "php-fpm: pool www",
|
||||||
|
"cpu_percent": 5.3,
|
||||||
|
"mem_percent": 4.1,
|
||||||
|
"cpu_time": 890.12,
|
||||||
|
"memory_bytes": 134217728
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user