6 Commits
Author SHA1 Message Date
Joel Male 6677b58005 chore: update collector v0.11.0 2026-02-26 21:44:30 +10:00
Joel Male 541c239b7e feat: add update-token command to replace API token in-place 2026-02-26 21:36:41 +10:00
Joel Male 28a555c1de chore: remove SSL cert monitoring (handled by Pulsewise) 2026-02-26 21:35:09 +10:00
Joel Male cc2b44b7cb chore: gitignore AI tool directories and remove tracked .claude files 2026-02-26 21:32:21 +10:00
Joel Male 3cb7bc7d60 docs: update README with correct install URL, 60s interval, and full capability list 2026-02-26 16:19:36 +10:00
Joel Male 4018ba5c4a feat: add PHP-FPM, nginx, SSL, and port monitoring
All four are opt-in via config file:
- FPM_STATUS_URL — PHP-FPM pool stats (active/idle workers, slow requests, max children reached, accepted conn)
- NGINX_STATUS_URL — nginx stub_status (active connections, reading/writing/waiting, accepts/handled/requests)
- SSL_DOMAINS — TLS cert expiry check per domain (days left, valid, expires_at)
- CHECK_PORTS — TCP port availability per port with known service name mapping

Also refactors loadConfigFromFile to return *Config directly instead of a positional tuple.
2026-02-26 11:50:54 +10:00
7 changed files with 409 additions and 632 deletions
-532
View File
@@ -1,532 +0,0 @@
# Session Handover
**Session:** `ba459302-a174-4ab7-b546-989c611afd2e`
**Project:** `/Users/joel/Code/pulsewise-collector`
**Saved:** 2026-02-15 16:02:41
---
## User Requests
1. We need to update this go package to: rename to pulsewise collector, an add a one click install script that is as simple as this: curl -fsSL https://openclaw.ai/install.sh | bash. It should then ask in a nice interactive flow some onboarding questions, like what is the pulsewise token to sync to, name the machine, etc
2. Now, the website is at ../pulsewise from this directory. Can you create a deploy script for me to compile the binaries, and move them to /public/collector/download in Laravel so I can deploy them? And the deploy script also copies the install script
3. Okay, now is linux-amd64 all we should support? Does that cover ubuntu, debian, etc?
4. yes
5. Can you also make this create a commit in the laravel directory? So updating the package or install script is always in its own commit?
6. Great, commit this please if you haven't already
7. Anyting else we could add to this collector to send more data to pulsewise that is worthy of tracking?
8. yes, add all the high value ones
9. Make sure you commit
10. How can I test what this json request looks like so I can have claude in the pulsewire file, know what the request is so we can implement it? Or can you give me a json dump example of what it sends?
11. Can you put the json dump in a payload.json in the root of this project?
12. How can I test this o a test server? How does the test url know to be used?
13. Can I test it by manually copying the binary to a linux server?
14. Ah yeah it working on macos hey?
15. Yep build and run the main.go for test mode
16. Can you let me run it here? And show me what I run with the token and test mode?
17. [Request interrupted by user]
18. No, build this for macos, and I will run it with TEST_MODE=true and TOKEN=
19. Does the install script install to a config file?
20. Can you add a build script so I can just run go build and it builds for my macos?
21. Can we add the test_mode into the config?
22. How is the token being sent?
23. For debug mode, can it console log the response?
24. Can we rename machine name to hostname?
25. [Request interrupted by user]
26. Can we rename machine name to hostname and if not manually set, it should get it automatically
27. Now the other validation it is failing is this: "cpu.core_count":["The cpu.core count field is required."]. Do we have a different field name, or not being sent?
28. What time range is the network data for?
29. Task: Update network metrics to send per-interval rates instead of cumulative totals
The Pulsewise API at POST /api/collect currently receives a network field as a flat object
with cumulative-since-boot counters:
{
"network": {
"bytes_recv": 30332568342,
"bytes_sent": 21713728388,
"packets_recv": 81513318,
"packets_sent": 75462736
}
}
These cumulative v...
30. Is there any more data we could be sending to make this more valuable?
31. Add the system object
32. Can we make this an even easier install? LIke somehow including the API token in the one line command so install doesn't even need to ask about it?
33. [Image: source: /Users/joel/Google Drive/joel@joelmale.com/Documents/Screenshots/CleanShot 2026-02-15 at 07.44.00@2x.png]
34. Can we also remove PULSEWISE_ before all of the config options? It's already our config so why do we need that?
35. No I don't want it to be prefixed.
36. commit your work that you've done
37. Do we need sudo to collect these metrics? I.e can we safely drop sudo from the one line install and everything still work?
38. Okay, we'll just open source this. What is a one line description for this for the github repository?
39. Can you make sure the README is up to date? I will be open sourcing this
40. Can we make the installer work for macos as well, incase they have macos servers?
## Key Actions Taken
### Bash (43 calls)
- `{'command': 'ls -la /Users/joel/Code/pulsewise-collector/', 'description': 'List project root'}`
- `{'command': 'go build -o /dev/null ./...', 'description': 'Check Go compilation'}`
- `{'command': 'ls -la /Users/joel/Code/pulsewise-collector/pingwire-collector /Users/joel/Code/pulsewise-collector/pingwire-collector-linux-amd64 2>/dev`
- `{'command': 'ls -la /Users/joel/Code/pulsewise/public/ 2>/dev/null', 'description': 'Check Laravel public directory'}`
- `{'command': 'chmod +x /Users/joel/Code/pulsewise-collector/deploy.sh', 'description': 'Make deploy script executable'}`
- ... and 38 more
### Read (43 calls)
- `{'file_path': '/Users/joel/Code/pulsewise-collector/main.go'}`
- `{'file_path': '/Users/joel/Code/pulsewise-collector/README.md'}`
- `{'file_path': '/Users/joel/Code/pulsewise-collector/go.mod'}`
- `{'file_path': '/Users/joel/Code/pulsewise-collector/config.example'}`
- `{'file_path': '/Users/joel/Code/pulsewise-collector/install.sh'}`
- ... and 38 more
### Edit (64 calls)
- `{'replace_all': False, 'file_path': '/Users/joel/Code/pulsewise-collector/go.mod', 'old_string': 'module pingwire-collector', 'new_string': 'module pu`
- `{'replace_all': False, 'file_path': '/Users/joel/Code/pulsewise-collector/main.go', 'old_string': '\tproductionURL = "https://openclaw.ai/api/coll`
- `{'replace_all': False, 'file_path': '/Users/joel/Code/pulsewise-collector/install.sh', 'old_string': '# Configuration\nBINARY_URL="https://pulsewise.a`
- `{'replace_all': False, 'file_path': '/Users/joel/Code/pulsewise-collector/deploy.sh', 'old_string': '# Compile for Linux amd64\ninfo "[1/3] Compiling `
- `{'replace_all': False, 'file_path': '/Users/joel/Code/pulsewise-collector/deploy.sh', 'old_string': '# Summary\ninfo "[4/4] Done."\necho ""\necho -e "`
- ... and 59 more
### Write (13 calls)
- `{'file_path': '/Users/joel/Code/pulsewise-collector/main.go', 'content': 'package main\n\nimport (\n\t"bufio"\n\t"bytes"\n\t"encoding/json"\n\t"fmt"\n`
- `{'file_path': '/Users/joel/Code/pulsewise-collector/install.sh', 'content': '#!/bin/bash\n\nset -e\n\n# ──────────────────────────────────────────────`
- `{'file_path': '/Users/joel/Code/pulsewise-collector/config.example', 'content': '# Pulsewise Collector Configuration\n# Copy this file to /etc/pulsewi`
- `{'file_path': '/Users/joel/Code/pulsewise-collector/README.md', 'content': '# Pulsewise Collector\n\nA lightweight Go agent that continuously collects`
- `{'file_path': '/Users/joel/Code/pulsewise-collector/deploy.sh', 'content': '#!/bin/bash\n\nset -e\n\nBOLD=\'\\033[1m\'\nDIM=\'\\033[2m\'\nGREEN=\'\\03`
- ... and 8 more
### Glob (1 calls)
- `{'pattern': '**/pingwire*', 'path': '/Users/joel/Code/pulsewise-collector'}`
### Grep (5 calls)
- `{'pattern': 'pingwire', 'path': '/Users/joel/Code/pulsewise-collector', '-i': True, 'output_mode': 'content'}`
- `{'pattern': 'PULSEWISE_TOKEN', 'path': '/Users/joel/Code/pulsewise-collector/install.sh', 'output_mode': 'content'}`
- `{'pattern': 'PULSEWISE_HOSTNAME', 'path': '/Users/joel/Code/pulsewise-collector/install.sh', 'output_mode': 'content'}`
- `{'pattern': 'COLLECTOR_HOSTNAME', 'path': '/Users/joel/Code/pulsewise-collector/install.sh', 'output_mode': 'content'}`
- `{'pattern': 'PULSEWISE_TOKEN', 'path': '/Users/joel/Code/pulsewise-collector/deploy.sh', 'output_mode': 'content'}`
### Task (1 calls)
- `{'description': 'Check gopsutil API availability', 'prompt': 'I need to verify what gopsutil v3 provides for these metrics. Search the go module cache`
## Assistant Responses (Summary)
- Let me start by exploring the current codebase to understand what we're working with.
- Now I have a clear picture. Let me implement this — rename everything from pingwire to pulsewise, add machine name support, and build a polished interactive install script.
A few assumptions I'll make:
- Production URL becomes `https://openclaw.ai/api/collect`
- Binary download from `https://opencl...
- Now let me rewrite `main.go` with all the renames and the new machine name support.
- Got it — updating to `pulsewise.app` for both the binary download URL and the production API endpoint. Let me update `main.go` and then write the install script and remaining files.
- Got it. Now let me write the install script and update the remaining files.
- Now let me update the config example and README.
- Let me verify the Go code compiles.
- Compiles clean (the warnings are from a third-party dependency, not our code). Let me also clean up the old binaries that shouldn't be in the repo.
- Good — the old binary files aren't tracked. Let me verify there are no leftover "pingwire" references in any tracked file.
- All clean. Here's a summary of everything that was done:
### Changes
**Renamed** the entire project from "Pingwire" to "Pulsewise":
- **`go.mod`** — module name `pulsewise-collector`
- **`main.go`** — all constants, config keys, env vars, log messages, and types updated:
- Production URL: `http...
- Let me check the Laravel project structure first.
- Let me test it compiles and deploys correctly.
- Works. The binary (8.4M) and install script are now at:
- `../pulsewise/public/collector/download/linux-amd64`
- `../pulsewise/public/collector/install.sh`
Run `./deploy.sh` any time you want to rebuild and push updates to the Laravel public directory. After deploying the Laravel app, the install ...
- Yes, `linux-amd64` covers Ubuntu, Debian, CentOS, RHEL, Fedora, Arch, etc. — basically any Linux distro running on standard x86_64 Intel/AMD hardware. That's the vast majority of servers.
The one gap is **ARM servers** (like AWS Graviton, Oracle Ampere, Hetzner CAX, Raspberry Pi). Those need `linux...
- Now add `linux-arm64` to the deploy script.
- Both binaries compile and deploy. Here's what changed:
**`install.sh`** — auto-detects architecture at runtime using `uname -m`, maps it to the right binary (`linux-amd64` or `linux-arm64`), and fails with a clear message on unsupported architectures.
**`deploy.sh`** — now compiles both `linux/amd...
- Let me also update the step counters to reflect the new total.
- Let me verify the final script looks right.
- Done. Step 4 now stages `public/collector/` in the Laravel repo and commits it. If nothing changed (e.g. you run deploy twice without modifying Go code or the install script), it skips the commit cleanly instead of erroring.
- Committed as `b2c01bf`.
- Here's what I'd actually find useful beyond what you have, roughly ordered by value:
**High value — most teams want these:**
- **Load average** (1m, 5m, 15m) — more telling than CPU % for understanding if a machine is struggling. A box can show 40% CPU but have a load of 12.
- **Swap usage** — if ...
- Now let me check what gopsutil offers for load average, swap, disk I/O, and disk partitions.
- All five are available in gopsutil. Let me update `main.go`.
- Now update the Metrics struct and add the new types.
- Now update the `collectMetrics` function to collect all the new data.
- Compiles clean. Let me commit.
- Done. Here's what was added:
| Metric | Fields | Why it matters |
|--------|--------|---------------|
| **Load average** | `load1`, `load5`, `load15` | Shows system pressure over time — better than CPU % alone |
| **Swap** | `total`, `used`, `free`, `used_percent` | Any swap usage = memory pressure...
- Already committed — that was `00cfca2` above. Let me verify.
- All committed, working tree is clean.
- Easiest way — let me just collect real metrics from this machine and dump the JSON.
- The `//go:build ignore` tag excludes `main.go`. Let me just use a separate temp directory instead.
- There's the real JSON payload. That's what gets `POST`ed to `https://pulsewise.app/api/collect` with header `Authorization: Bearer <token>`.
One thing I notice — on macOS the disk partitions list includes a lot of system volumes (`/System/Volumes/VM`, `/System/Volumes/Preboot`, etc). On a Linux ser...
- Done. I cleaned up the values to look like a realistic Linux server (ext4 partitions, `/` and `/var` mounts, sensible load/memory numbers, 3 example processes) rather than the raw macOS dump.
- Looking at `main.go:105`, test mode is triggered by the `PULSEWISE_TEST_MODE` env var:
```bash
PULSEWISE_TEST_MODE=true ./pulsewise-collector
```
But the current test URL is `https://64215798ca4d6a42a266g1w48zayyyyyb.oast.pro` — that looks like an old OAST/Burp Collaborator URL that probably isn't...
- Yes, just cross-compile and copy it over:
```bash
# Build for Linux
GOOS=linux GOARCH=amd64 go build -o pulsewise-collector
# Copy to your server
scp pulsewise-collector user@your-server:/tmp/
```
Then on the server:
```bash
# Create the config
sudo mkdir -p /etc/pulsewise-collector
sudo tee /et...
- Yeah it works on macOS too — gopsutil supports it. You can just run it locally:
```bash
# Create local config
mkdir -p ~/.config/pulsewise-collector
cat > ~/.config/pulsewise-collector/config << 'EOF'
PULSEWISE_TOKEN=test-token
PULSEWISE_MACHINE_NAME=dev-laptop
EOF
# Run it
go run main.go
```
The...
- It works. The collector starts up, collects all the metrics (CPU sampling takes a couple seconds), and tries to POST them. The `404` is expected — `pulsewise.app/api/collect` doesn't have a receiving endpoint yet. Once you build that in Laravel, it'll start going through.
- Sure. First let me add that URL override so test mode is actually useful.
- Let me add `PULSEWISE_URL` support to the config file, remove the old test mode/OAST URL, and rebuild.
- Now update the Config struct (remove TestMode), loadConfig, and loadConfigFromFile to return the URL too.
- Now update `loadConfigFromFile` to parse `PULSEWISE_URL` and return it.
- Built. Your config file is already at `~/.config/pulsewise-collector/config` from earlier. Run:
```bash
PULSEWISE_TEST_MODE=true ./pulsewise-collector
```
The token and machine name come from the config file. To change them:
```bash
cat > ~/.config/pulsewise-collector/config << 'EOF'
PULSEWISE_TO...
- Yes — the install script writes the token and machine name to `/etc/pulsewise-collector/config` at step 2 (`install.sh:200-207`). It creates:
```
# Pulsewise Collector Configuration
# Docs: https://pulsewise.app/docs/collector
PULSEWISE_TOKEN=<whatever they entered>
PULSEWISE_MACHINE_NAME=<whateve...
- Done. Just run `./build.sh` then `./pulsewise-collector`.
- Now update the return and the caller to use the URL from config.
- Now update `loadConfig` to use the new signature and drop the env var/test mode logic.
- The file was reverted externally — the constant is still `productionURL`. Let me check.
- Builds clean. Now your config file controls everything. To test, just set `PULSEWISE_URL` in the config:
```
PULSEWISE_TOKEN=your-token
PULSEWISE_MACHINE_NAME=joels-mac
PULSEWISE_URL=https://pulsewise.test/api/collect
```
Omit `PULSEWISE_URL` and it defaults to `https://pulsewise.app/api/collect`....
- As a Bearer token in the `Authorization` header. From `main.go:399`:
```go
req.Header.Set("Authorization", "Bearer "+config.Token)
```
So the request looks like:
```
POST /api/collect HTTP/1.1
Host: pulsewise.app
Content-Type: application/json
Authorization: Bearer pw_tok_example_abc123
{ ...met...
- I'll add a `PULSEWISE_DEBUG` config option that logs the response status and body. Let me also check the Config struct and loadConfigFromFile.
- Now add parsing in `loadConfigFromFile`.
- I need to update the function signature and parsing to include debug, then update `loadConfig` and `sendMetrics`.
- Now update the function signature and return.
- Now update all the error returns and the final return.
- Now update `loadConfig` to receive the debug flag.
- Now update `sendMetrics` to log the response when debug is on.
- The named returns conflict with the local vars. Let me fix.
- Builds clean. Now add `PULSEWISE_DEBUG=true` to your config:
```
PULSEWISE_TOKEN=your-token
PULSEWISE_MACHINE_NAME=joels-mac
PULSEWISE_URL=https://pulsewise.test/api/collect
PULSEWISE_DEBUG=true
```
With debug on, it'll log the full response body after each send:
```
[DEBUG] Response 200: {"statu...
- Renaming `MachineName` to `Hostname` everywhere, and auto-detecting from `os.Hostname()` if not set in config.
- Now rename the config key and make it optional with auto-detection.
- Now remove the error when hostname is empty and auto-detect instead.
- Now replace the hostname-missing error with auto-detection.
- Now update the log line and install script too.
- Now update the install script and config example.
- Now update the config file writing and the summary output.
- Now update the summary at the bottom.
- Now update the config example and payload.json.
- Done. Changes:
- **Renamed** `machine_name` to `hostname` everywhere (Go structs, JSON keys, config file, install script, payload example)
- **Auto-detects** from `os.Hostname()` if `PULSEWISE_HOSTNAME` is not set in config — it's now optional
- Updated `config.example` to document all optional fie...
- The JSON key is `count` (nested under `cpu`), so Laravel sees it as `cpu.count`. But the validation rule is checking for `cpu.core_count`.
Either rename the Go JSON tag to match, or update the Laravel validation. Since `core_count` is more descriptive, let me update the Go side.
- Now sends `cpu.core_count` instead of `cpu.count`.
- It's cumulative since boot — total bytes/packets since the system started. It's not a rate or a per-interval snapshot.
If you want to show useful dashboard data (like MB/s or packets/s), you'd calculate the delta between two consecutive readings on the Laravel side:
```
rate = (current.bytes_sent ...
- First, update the `NetworkMetrics` struct and make `Network` nullable in `Metrics`.
- Make `Network` a pointer so it serializes as `null` when nil.
- Now add the global state variable and update `main()` to pass it through, and rewrite the network collection in `collectMetrics`.
- Now replace the network collection block in `collectMetrics`.
- Now update the payload example.
- Done. Here's how it works:
- **First collection**: `network` is `null` (no previous reading to diff against)
- **Second collection onward**: computes per-second rates by diffing current vs previous counters, divided by elapsed seconds
- **Counter reset** (reboot): if current values are lower than p...
- Looking at what you already have, the biggest gap is **system info** — static metadata about the machine itself. Everything else you're collecting is time-series metrics, but you have no way to answer "what OS is this server running?" or "when was the last kernel update?" in your dashboard.
I'd add...
- Now add the collection logic. I'll put it right after the uptime block.
- Now update the payload example.
- Done. Every payload now includes a `system` object:
```json
"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"
}
```
...
- Nice. Pass the token as an env var in the curl command. The install becomes truly one-line:
```bash
curl -sSL https://pulsewise.app/install-collector.sh | PULSEWISE_TOKEN=pw_tok_xxx bash
```
Let me update the install script to pick up `PULSEWISE_TOKEN` from the environment and only prompt if it's ...
- Now update the install URL in the script header and the error message to match what's in the screenshot (`pulsewise.app/install-collector.sh`).
- Now update the deploy script to copy to the right path for the new URL.
- Done. The install command shown in your dashboard can now be:
```bash
curl -sSL https://pulsewise.app/install-collector.sh | PULSEWISE_TOKEN=pw_tok_2be54be4-45f6-4751-85cb-1d3a5bd5f501 sudo -E bash
```
- If `PULSEWISE_TOKEN` is set via env, the script skips the token prompt entirely
- If `PULSEWIS...
- Update the error message too.
- Now update the install script — the env var stays `PULSEWISE_TOKEN` (that's what users pass on the command line), but the config file it writes uses the short keys.
- Now update config.example.
- Update local dev config too.
- Done. Config file is now clean:
```
TOKEN=your-token-here
HOSTNAME=my-server
URL=http://localhost:8000/api/collect
DEBUG=true
```
The `PULSEWISE_TOKEN` env var name is kept for the install one-liner (since that's a shell env var, not our config file — prefixing makes sense there).
- I used `COLLECTOR_HOSTNAME` for the env var because `HOSTNAME` is a built-in shell variable — using it as an env var in the curl pipe would silently get overwritten by the system hostname. Let me fix the config file output to still write `HOSTNAME=` though.
- Good — the config file at line 226 correctly writes `HOSTNAME=$COLLECTOR_HOSTNAME`. Now update the deploy script summary too.
- Done. The install one-liner is now:
```bash
curl -sSL https://pulsewise.app/install-collector.sh | TOKEN=pw_tok_xxx sudo -E bash
```
No `PULSEWISE_` prefix anywhere — config file, env vars, or install script.
- Committed as `ae45d4f`. Excluded the binary (`pulsewise-collector`) and `dump.txt` from the commit.
- The **install script** needs root — it writes to `/usr/local/bin/`, `/etc/pulsewise-collector/`, and `/etc/systemd/system/`. No way around that.
The **running binary** mostly doesn't need root. CPU, memory, swap, load, uptime, disk usage, network I/O, system info — all readable by any user. The one...
- > Lightweight system metrics collector that reports CPU, memory, disk, network, and process data to Pulsewise.
- The README is quite outdated. Let me rewrite it to match the current state.
- Done. The README now matches the current state — unprefixed config keys, one-liner install with `TOKEN=`, all metrics documented in a table, link to `payload.json`, debug mode, cross-compiling, and the `deploy.sh` workflow.
- Now let me rewrite this to support both Linux (systemd) and macOS (launchd), and update the deploy script to compile macOS binaries too.
+7
View File
@@ -1,3 +1,10 @@
dump* dump*
deploy.sh deploy.sh
pulsewise-collector pulsewise-collector
# AI tools
.claude/
CLAUDE.md
.cursor/
.copilot/
.aider*
+72 -35
View File
@@ -1,40 +1,55 @@
# Pulsewise Collector # Pulsewise Collector
Lightweight system metrics collector that reports CPU, memory, disk, network, and process data to [Pulsewise](https://pulsewise.app). Lightweight system metrics collector that reports to [Pulsewise](https://pulsewise.app).
## Quick Install ## Quick Install
```bash ```bash
curl -sSL https://pulsewise.app/install-collector.sh | TOKEN=your-token sudo -E bash curl -sSL https://pulsewise.app/collector/install.sh | TOKEN=<your-token> sudo -E bash
``` ```
That's it. The script downloads the binary, writes the config, and starts a systemd service. Hostname is auto-detected. The script downloads the binary, writes the config, registers the service, and starts it. Hostname is auto-detected from the system. If you omit the `TOKEN=` prefix, the script will prompt for it interactively.
If you prefer an interactive install (prompts for token and hostname):
```bash
curl -sSL https://pulsewise.app/install-collector.sh | sudo bash
```
## What It Collects ## What It Collects
Every 30 seconds, the collector sends: Every 60 seconds, the collector sends:
| Category | Metrics | | Category | Metrics |
|----------|---------| |----------|---------|
| **System** | OS, platform, platform version, kernel version, architecture, virtualization type, CPU model | | **System** | OS, platform, platform version, kernel, architecture, virtualization type, CPU model |
| **Uptime** | Seconds since boot | | **Uptime** | Seconds since boot |
| **Load average** | 1m, 5m, 15m | | **Load average** | 1m, 5m, 15m |
| **CPU** | Overall %, per-core %, core count | | **CPU** | Overall %, per-core %, iowait %, core count |
| **Memory** | Total, available, used, usage % | | **Memory** | Total, available, used, usage % |
| **Swap** | Total, used, free, usage % | | **Swap** | Total, used, free, usage % |
| **Disks** | Per mount point: device, fstype, total, free, used, usage % | | **Disks** | Per physical mount: device, fstype, total, free, used, usage % (virtual/loop mounts excluded) |
| **Disk I/O** | Read/write bytes, read/write count, IOPS in progress | | **Disk I/O** | Cumulative read/write bytes, read/write count, IOPS in progress |
| **Network** | Bytes and packets sent/received per second (rate, not cumulative) | | **Network** | Bytes and packets sent/received per second (per-second rate, not cumulative) |
| **TCP** | Established, TIME_WAIT, CLOSE_WAIT, and LISTEN connection counts |
| **File descriptors** | Open fd count and system maximum |
| **Processes** | Top 10 by CPU: PID, name, command, CPU%, memory%, CPU time, RSS | | **Processes** | Top 10 by CPU: PID, name, command, CPU%, memory%, CPU time, RSS |
Optional integrations (configured separately):
| Category | Metrics |
|----------|---------|
| **PHP-FPM** | Active/idle/total workers, max children reached, slow requests, listen queue, accepted connections |
| **Nginx** | Active connections, reading/writing/waiting, accepts/handled/requests |
| **Port checks** | TCP reachability per port with service name (ssh, mysql, redis, etc.) |
See [`payload.json`](payload.json) for a full example of the JSON sent to the API. See [`payload.json`](payload.json) for a full example of the JSON sent to the API.
## CLI Commands
```bash
sudo pulsewise-collector version # show version, check for updates
sudo pulsewise-collector update # download and install latest version
sudo pulsewise-collector update-token <new-token> # replace the API token and restart
sudo pulsewise-collector status # show config, service state, last pulse
sudo pulsewise-collector dump # print full metrics payload as JSON
sudo pulsewise-collector uninstall # remove binary, config, and service
```
## Configuration ## Configuration
Config lives at `/etc/pulsewise-collector/config`: Config lives at `/etc/pulsewise-collector/config`:
@@ -50,12 +65,50 @@ ## Configuration
| `TOKEN` | *(required)* | Your Pulsewise API token | | `TOKEN` | *(required)* | Your Pulsewise API token |
| `HOSTNAME` | System hostname | Override the reported hostname | | `HOSTNAME` | System hostname | Override the reported hostname |
| `URL` | `https://pulsewise.app/api/collect` | Override the API endpoint | | `URL` | `https://pulsewise.app/api/collect` | Override the API endpoint |
| `DEBUG` | `false` | Log full response body from the server | | `AUTO_UPDATE` | `true` | Set to `false` to disable automatic updates |
| `DEBUG` | `false` | Log full API response body |
### Optional integrations
| Key | Example | Description |
|-----|---------|-------------|
| `FPM_STATUS_URL` | `http://127.0.0.1/fpm-status` | PHP-FPM status page URL (`pm.status_path` must be set in your pool config) |
| `NGINX_STATUS_URL` | `http://127.0.0.1/nginx-status` | Nginx `stub_status` URL |
| `CHECK_PORTS` | `22,80,443,3306,6379` | Comma-separated ports to check TCP availability on |
See [`config.example`](config.example) for a commented reference.
Config file permissions should be `600` (root-only readable). Config file permissions should be `600` (root-only readable).
For local development on macOS, the collector also checks `~/.config/pulsewise-collector/config`. For local development on macOS, the collector also checks `~/.config/pulsewise-collector/config`.
## Managing the Service
**Linux (systemd)**
```bash
sudo systemctl status pulsewise-collector
sudo journalctl -u pulsewise-collector -f
sudo systemctl restart pulsewise-collector
sudo systemctl stop pulsewise-collector
```
**macOS (launchd)**
```bash
tail -f /var/log/pulsewise-collector.log
sudo launchctl kickstart -k system/app.pulsewise.collector
sudo launchctl bootout system /Library/LaunchDaemons/app.pulsewise.collector.plist
```
## How It Works
- Collects metrics every **60 seconds** using [gopsutil](https://github.com/shirou/gopsutil)
- CPU and process metrics are sampled concurrently over the same 1-second window to keep them aligned
- Virtual/pseudo filesystems (tmpfs, squashfs, loop devices, etc.) are excluded from disk metrics
- Network metrics are per-second rates computed from the delta between collections; the first collection omits network
- Sends JSON via `POST` to the configured URL with `Authorization: Bearer <token>`
- Checks for updates at startup (after 30s) and every 6 hours; updates are applied via service restart
- Runs as a systemd (Linux) or launchd (macOS) service with automatic restart on failure
## Development ## Development
```bash ```bash
@@ -79,27 +132,11 @@ # Build and run
## Cross-Compiling ## Cross-Compiling
```bash ```bash
GOOS=linux GOARCH=amd64 go build -o pulsewise-collector-linux-amd64 GOOS=linux GOARCH=amd64 go build -o pulsewise-collector-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -o pulsewise-collector-linux-arm64 GOOS=linux GOARCH=arm64 go build -o pulsewise-collector-linux-arm64 .
``` ```
Or use `./deploy.sh` to compile both architectures and copy them into the Pulsewise Laravel app. Or run `./deploy.sh` to bump the version, compile all targets, and publish a release.
## Managing the Service
```bash
sudo systemctl status pulsewise-collector
sudo journalctl -u pulsewise-collector -f
sudo systemctl restart pulsewise-collector
sudo systemctl stop pulsewise-collector
```
## How It Works
- Collects metrics every 30 seconds using [gopsutil](https://github.com/shirou/gopsutil)
- Sends JSON via `POST` to the configured URL with `Authorization: Bearer <token>`
- Network metrics are computed as per-second rates (delta between intervals); the first collection sends `null` for network
- Runs as a systemd service with automatic restart on failure
## License ## License
+1 -1
View File
@@ -1 +1 @@
0.10.0 0.11.0
+13
View File
@@ -14,3 +14,16 @@ TOKEN=your-token-here
# Optional — logs full response body from the server # Optional — logs full response body from the server
# DEBUG=true # DEBUG=true
# ─────────────────────────────────────────────────────────────
# Optional integrations
# ─────────────────────────────────────────────────────────────
# PHP-FPM status page URL (requires pm.status_path = /status in php-fpm pool config)
# FPM_STATUS_URL=http://127.0.0.1/fpm-status
# Nginx stub_status URL (requires stub_status module and location block)
# NGINX_STATUS_URL=http://127.0.0.1/nginx-status
# TCP port availability checks — comma-separated list of port numbers
# CHECK_PORTS=22,80,443,3306,6379
+278 -54
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
@@ -24,7 +25,7 @@ import (
"github.com/shirou/gopsutil/v3/host" "github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/load" "github.com/shirou/gopsutil/v3/load"
"github.com/shirou/gopsutil/v3/mem" "github.com/shirou/gopsutil/v3/mem"
"github.com/shirou/gopsutil/v3/net" psnet "github.com/shirou/gopsutil/v3/net"
"github.com/shirou/gopsutil/v3/process" "github.com/shirou/gopsutil/v3/process"
) )
@@ -66,6 +67,9 @@ type Config struct {
Interval time.Duration Interval time.Duration
Debug bool Debug bool
AutoUpdate bool AutoUpdate bool
FPMStatusURL string
NginxStatusURL string
CheckPorts []int
} }
type Metrics struct { type Metrics struct {
@@ -86,6 +90,9 @@ type Metrics struct {
Processes []ProcessInfo `json:"processes"` Processes []ProcessInfo `json:"processes"`
TCP *TCPStats `json:"tcp,omitempty"` TCP *TCPStats `json:"tcp,omitempty"`
FileDescriptors *FileDescriptors `json:"file_descriptors,omitempty"` FileDescriptors *FileDescriptors `json:"file_descriptors,omitempty"`
FPM *FPMPoolStats `json:"fpm,omitempty"`
Ports []PortCheck `json:"ports,omitempty"`
Nginx *NginxStats `json:"nginx,omitempty"`
} }
type SystemInfo struct { type SystemInfo struct {
@@ -124,6 +131,35 @@ type FileDescriptors struct {
Max int `json:"max"` Max int `json:"max"`
} }
type FPMPoolStats struct {
Pool string `json:"pool"`
ActiveProcesses int `json:"active_processes"`
IdleProcesses int `json:"idle_processes"`
TotalProcesses int `json:"total_processes"`
MaxActiveProcesses int `json:"max_active_processes"`
MaxChildrenReached int `json:"max_children_reached"`
ListenQueue int `json:"listen_queue"`
MaxListenQueue int `json:"max_listen_queue"`
SlowRequests int `json:"slow_requests"`
AcceptedConn int `json:"accepted_conn"`
}
type PortCheck struct {
Port int `json:"port"`
Open bool `json:"open"`
Service string `json:"service,omitempty"`
}
type NginxStats struct {
ActiveConnections int `json:"active_connections"`
Reading int `json:"reading"`
Writing int `json:"writing"`
Waiting int `json:"waiting"`
Accepts int `json:"accepts"`
Handled int `json:"handled"`
Requests int `json:"requests"`
}
type MemoryMetrics struct { type MemoryMetrics struct {
Total uint64 `json:"total"` Total uint64 `json:"total"`
Available uint64 `json:"available"` Available uint64 `json:"available"`
@@ -205,6 +241,9 @@ func main() {
case "dump": case "dump":
runDumpCommand() runDumpCommand()
return return
case "update-token":
runUpdateTokenCommand()
return
} }
} }
@@ -359,6 +398,60 @@ func reexecWithSudo() {
} }
} }
func runUpdateTokenCommand() {
if os.Getuid() != 0 {
reexecWithSudo()
return
}
if len(os.Args) < 3 || strings.TrimSpace(os.Args[2]) == "" {
fmt.Fprintf(os.Stderr, "\n %sUsage:%s pulsewise-collector update-token <new-token>\n\n", ansiBold, ansiReset)
os.Exit(1)
}
newToken := strings.TrimSpace(os.Args[2])
// Resolve config path (same logic as loadConfigFromFile)
configPath := configFilePath
if _, err := os.Stat(configPath); os.IsNotExist(err) {
homeDir, _ := os.UserHomeDir()
if homeDir != "" {
local := filepath.Join(homeDir, ".config", "pulsewise-collector", "config")
if _, err := os.Stat(local); err == nil {
configPath = local
}
}
}
// Read existing config, replace TOKEN line
data, err := os.ReadFile(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "\n %sError:%s could not read config: %v\n\n", ansiRed, ansiReset, err)
os.Exit(1)
}
lines := strings.Split(string(data), "\n")
tokenFound := false
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "TOKEN=") {
lines[i] = "TOKEN=" + newToken
tokenFound = true
break
}
}
if !tokenFound {
// Prepend if TOKEN line is somehow missing
lines = append([]string{"TOKEN=" + newToken}, lines...)
}
if err := os.WriteFile(configPath, []byte(strings.Join(lines, "\n")), 0600); err != nil {
fmt.Fprintf(os.Stderr, "\n %sError:%s could not write config: %v\n\n", ansiRed, ansiReset, err)
os.Exit(1)
}
fmt.Printf("\n %sToken updated.%s\n", ansiGreen, ansiReset)
restartService()
}
func runStatusCommand() { func runStatusCommand() {
if os.Getuid() != 0 { if os.Getuid() != 0 {
reexecWithSudo() reexecWithSudo()
@@ -370,24 +463,24 @@ func runStatusCommand() {
fmt.Printf("\n%spulsewise-collector%s v%s\n\n", ansiBold, ansiReset, version) fmt.Printf("\n%spulsewise-collector%s v%s\n\n", ansiBold, ansiReset, version)
// Config // Config
token, hostname, url, _, autoUpdate, err := loadConfigFromFile() cfg, err := loadConfigFromFile()
if err != nil { if err != nil {
fmt.Printf("%s Not configured — %v\n\n", ansiRed, ansiReset) fmt.Printf("%s Not configured — %v\n\n", ansiRed, ansiReset)
} else { } else {
maskedToken := token maskedToken := cfg.Token
if len(token) >= 8 { if len(cfg.Token) >= 8 {
maskedToken = token[:4] + "..." + token[len(token)-4:] maskedToken = cfg.Token[:4] + "..." + cfg.Token[len(cfg.Token)-4:]
} else { } else {
maskedToken = "****" maskedToken = "****"
} }
autoUpdateStr := ansiGreen + "enabled" + ansiReset autoUpdateStr := ansiGreen + "enabled" + ansiReset
if !autoUpdate { if !cfg.AutoUpdate {
autoUpdateStr = ansiDim + "disabled" + ansiReset autoUpdateStr = ansiDim + "disabled" + ansiReset
} }
fmt.Printf("%sCollector%s\n", ansiBold, ansiReset) fmt.Printf("%sCollector%s\n", ansiBold, ansiReset)
fmt.Printf("%s %s\n", label("Hostname"), hostname) fmt.Printf("%s %s\n", label("Hostname"), cfg.Hostname)
fmt.Printf("%s %s\n", label("Token"), maskedToken) fmt.Printf("%s %s\n", label("Token"), maskedToken)
fmt.Printf("%s %s\n", label("API endpoint"), url) fmt.Printf("%s %s\n", label("API endpoint"), cfg.URL)
fmt.Printf("%s %s\n", label("Interval"), collectionInterval) fmt.Printf("%s %s\n", label("Interval"), collectionInterval)
fmt.Printf("%s %s\n", label("Auto-update"), autoUpdateStr) fmt.Printf("%s %s\n", label("Auto-update"), autoUpdateStr)
fmt.Println() fmt.Println()
@@ -717,22 +810,14 @@ func execSelf() {
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
func loadConfig() *Config { func loadConfig() *Config {
token, hostname, url, debug, autoUpdate, err := loadConfigFromFile() cfg, err := loadConfigFromFile()
if err != nil { if err != nil {
log.Fatalf("Failed to load config: %v", err) log.Fatalf("Failed to load config: %v", err)
} }
return cfg
return &Config{
Token: token,
Hostname: hostname,
URL: url,
Interval: collectionInterval,
Debug: debug,
AutoUpdate: autoUpdate,
}
} }
func loadConfigFromFile() (string, string, string, bool, bool, error) { func loadConfigFromFile() (*Config, error) {
configPath := configFilePath configPath := configFilePath
if _, err := os.Stat(configPath); os.IsNotExist(err) { if _, err := os.Stat(configPath); os.IsNotExist(err) {
@@ -747,13 +832,15 @@ func loadConfigFromFile() (string, string, string, bool, bool, error) {
file, err := os.Open(configPath) file, err := os.Open(configPath)
if err != nil { if err != nil {
return "", "", "", false, false, fmt.Errorf("config file not found at %s: %w", configPath, err) return nil, fmt.Errorf("config file not found at %s: %w", configPath, err)
} }
defer file.Close() defer file.Close()
var token, hostname, url string cfg := &Config{
var debug bool URL: defaultURL,
autoUpdate := true Interval: collectionInterval,
AutoUpdate: true,
}
scanner := bufio.NewScanner(file) scanner := bufio.NewScanner(file)
for scanner.Scan() { for scanner.Scan() {
@@ -761,46 +848,48 @@ func loadConfigFromFile() (string, string, string, bool, bool, error) {
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
continue continue
} }
if strings.HasPrefix(line, "TOKEN=") { key, val, ok := strings.Cut(line, "=")
token = strings.TrimPrefix(line, "TOKEN=") if !ok {
token = strings.Trim(token, `"'`) continue
} }
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, `"'`) val = strings.Trim(val, `"'`)
debug = val == "true" || val == "1" switch key {
case "TOKEN":
cfg.Token = val
case "HOSTNAME":
cfg.Hostname = val
case "URL":
cfg.URL = val
case "DEBUG":
cfg.Debug = val == "true" || val == "1"
case "AUTO_UPDATE":
cfg.AutoUpdate = val != "false" && val != "0"
case "FPM_STATUS_URL":
cfg.FPMStatusURL = val
case "NGINX_STATUS_URL":
cfg.NginxStatusURL = val
case "CHECK_PORTS":
for _, p := range strings.Split(val, ",") {
p = strings.TrimSpace(p)
if port, err := strconv.Atoi(p); err == nil && port > 0 && port < 65536 {
cfg.CheckPorts = append(cfg.CheckPorts, port)
}
} }
if strings.HasPrefix(line, "AUTO_UPDATE=") {
val := strings.TrimPrefix(line, "AUTO_UPDATE=")
val = strings.Trim(val, `"'`)
autoUpdate = val != "false" && val != "0"
} }
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return "", "", "", false, false, fmt.Errorf("error reading config file: %w", err) return nil, fmt.Errorf("error reading config file: %w", err)
} }
if token == "" { if cfg.Token == "" {
return "", "", "", false, false, fmt.Errorf("TOKEN not found in config file") return nil, fmt.Errorf("TOKEN not found in config file")
} }
if hostname == "" { if cfg.Hostname == "" {
hostname, _ = os.Hostname() cfg.Hostname, _ = os.Hostname()
} }
if url == "" { return cfg, nil
url = defaultURL
}
return token, hostname, url, debug, autoUpdate, nil
} }
func collectAndSend(config *Config) { func collectAndSend(config *Config) {
@@ -1037,7 +1126,7 @@ func collectMetrics(config *Config) (*Metrics, error) {
} }
// Network (per-second rates) // Network (per-second rates)
netStats, err := net.IOCounters(false) netStats, err := psnet.IOCounters(false)
if err != nil { if err != nil {
log.Printf("Warning: Failed to get network stats: %v", err) log.Printf("Warning: Failed to get network stats: %v", err)
} else if len(netStats) > 0 { } else if len(netStats) > 0 {
@@ -1086,15 +1175,26 @@ func collectMetrics(config *Config) (*Metrics, error) {
metrics.TCP = collectTCPStats() metrics.TCP = collectTCPStats()
metrics.FileDescriptors = collectFileDescriptors() metrics.FileDescriptors = collectFileDescriptors()
// Optional integrations (only collected when configured)
if config.FPMStatusURL != "" {
metrics.FPM = collectFPMStats(config.FPMStatusURL)
}
if config.NginxStatusURL != "" {
metrics.Nginx = collectNginxStats(config.NginxStatusURL)
}
if len(config.CheckPorts) > 0 {
metrics.Ports = collectPortChecks(config.CheckPorts)
}
return metrics, nil return metrics, nil
} }
func collectTCPStats() *TCPStats { func collectTCPStats() *TCPStats {
conns, err := net.Connections("tcp") conns, err := psnet.Connections("tcp")
if err != nil { if err != nil {
return nil return nil
} }
conns6, _ := net.Connections("tcp6") conns6, _ := psnet.Connections("tcp6")
conns = append(conns, conns6...) conns = append(conns, conns6...)
stats := &TCPStats{} stats := &TCPStats{}
@@ -1159,6 +1259,130 @@ func collectFileDescriptors() *FileDescriptors {
return nil return nil
} }
// knownPortServices maps common port numbers to service names.
var knownPortServices = map[int]string{
21: "ftp",
22: "ssh",
25: "smtp",
80: "http",
443: "https",
3306: "mysql",
5432: "postgres",
6379: "redis",
8080: "http-alt",
8443: "https-alt",
9200: "elasticsearch",
11211: "memcached",
27017: "mongodb",
}
func collectFPMStats(statusURL string) *FPMPoolStats {
url := statusURL
if strings.Contains(url, "?") {
url += "&json"
} else {
url += "?json"
}
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
// PHP-FPM JSON keys contain spaces (e.g. "active processes")
var data struct {
Pool string `json:"pool"`
ActiveProcesses int `json:"active processes"`
IdleProcesses int `json:"idle processes"`
TotalProcesses int `json:"total processes"`
MaxActiveProcesses int `json:"max active processes"`
MaxChildrenReached int `json:"max children reached"`
ListenQueue int `json:"listen queue"`
MaxListenQueue int `json:"max listen queue"`
SlowRequests int `json:"slow requests"`
AcceptedConn int `json:"accepted conn"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil
}
return &FPMPoolStats{
Pool: data.Pool,
ActiveProcesses: data.ActiveProcesses,
IdleProcesses: data.IdleProcesses,
TotalProcesses: data.TotalProcesses,
MaxActiveProcesses: data.MaxActiveProcesses,
MaxChildrenReached: data.MaxChildrenReached,
ListenQueue: data.ListenQueue,
MaxListenQueue: data.MaxListenQueue,
SlowRequests: data.SlowRequests,
AcceptedConn: data.AcceptedConn,
}
}
func collectNginxStats(statusURL string) *NginxStats {
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(statusURL)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil
}
// Nginx stub_status format:
// Active connections: 291
// server accepts handled requests
// 16630948 16630948 31070465
// Reading: 6 Writing: 179 Waiting: 106
stats := &NginxStats{}
lines := strings.Split(string(body), "\n")
for i, line := range lines {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "Active connections:"):
fmt.Sscanf(strings.TrimPrefix(line, "Active connections:"), " %d", &stats.ActiveConnections)
case strings.HasPrefix(line, "Reading:"):
fmt.Sscanf(line, "Reading: %d Writing: %d Waiting: %d", &stats.Reading, &stats.Writing, &stats.Waiting)
case i > 0 && strings.TrimSpace(lines[i-1]) == "server accepts handled requests":
fmt.Sscanf(line, "%d %d %d", &stats.Accepts, &stats.Handled, &stats.Requests)
}
}
return stats
}
func collectPortChecks(ports []int) []PortCheck {
results := make([]PortCheck, 0, len(ports))
for _, port := range ports {
addr := fmt.Sprintf("127.0.0.1:%d", port)
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
open := err == nil
if open {
conn.Close()
}
results = append(results, PortCheck{
Port: port,
Open: open,
Service: knownPortServices[port],
})
}
return results
}
func collectTopProcesses() ([]ProcessInfo, error) { func collectTopProcesses() ([]ProcessInfo, error) {
pids, err := process.Pids() pids, err := process.Pids()
if err != nil { if err != nil {
+29 -1
View File
@@ -114,5 +114,33 @@
"cpu_time": 890.12, "cpu_time": 890.12,
"memory_bytes": 134217728 "memory_bytes": 134217728
} }
] ],
"fpm": {
"pool": "www",
"active_processes": 3,
"idle_processes": 7,
"total_processes": 10,
"max_active_processes": 8,
"max_children_reached": 0,
"listen_queue": 0,
"max_listen_queue": 2,
"slow_requests": 0,
"accepted_conn": 84291
},
"ports": [
{ "port": 22, "open": true, "service": "ssh" },
{ "port": 80, "open": true, "service": "http" },
{ "port": 443, "open": true, "service": "https" },
{ "port": 3306, "open": true, "service": "mysql" },
{ "port": 6379, "open": false, "service": "redis" }
],
"nginx": {
"active_connections": 42,
"reading": 1,
"writing": 8,
"waiting": 33,
"accepts": 1847293,
"handled": 1847293,
"requests": 3921847
}
} }