mirror of
https://github.com/wearepixel/pulsewise-collector.git
synced 2026-11-14 14:01:50 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6677b58005 | ||
|
|
541c239b7e | ||
|
|
28a555c1de | ||
|
|
cc2b44b7cb | ||
|
|
3cb7bc7d60 | ||
|
|
4018ba5c4a |
@@ -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.
|
||||
+8
-1
@@ -1,3 +1,10 @@
|
||||
dump*
|
||||
deploy.sh
|
||||
pulsewise-collector
|
||||
pulsewise-collector
|
||||
|
||||
# AI tools
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.cursor/
|
||||
.copilot/
|
||||
.aider*
|
||||
@@ -1,40 +1,55 @@
|
||||
# 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
|
||||
|
||||
```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.
|
||||
|
||||
If you prefer an interactive install (prompts for token and hostname):
|
||||
|
||||
```bash
|
||||
curl -sSL https://pulsewise.app/install-collector.sh | sudo bash
|
||||
```
|
||||
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.
|
||||
|
||||
## What It Collects
|
||||
|
||||
Every 30 seconds, the collector sends:
|
||||
Every 60 seconds, the collector sends:
|
||||
|
||||
| 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 |
|
||||
| **Load average** | 1m, 5m, 15m |
|
||||
| **CPU** | Overall %, per-core %, core count |
|
||||
| **CPU** | Overall %, per-core %, iowait %, core count |
|
||||
| **Memory** | Total, available, used, usage % |
|
||||
| **Swap** | Total, used, free, usage % |
|
||||
| **Disks** | Per mount point: device, fstype, total, free, used, usage % |
|
||||
| **Disk I/O** | Read/write bytes, read/write count, IOPS in progress |
|
||||
| **Network** | Bytes and packets sent/received per second (rate, not cumulative) |
|
||||
| **Disks** | Per physical mount: device, fstype, total, free, used, usage % (virtual/loop mounts excluded) |
|
||||
| **Disk I/O** | Cumulative read/write bytes, read/write count, IOPS in progress |
|
||||
| **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 |
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
Config lives at `/etc/pulsewise-collector/config`:
|
||||
@@ -50,12 +65,50 @@ ## Configuration
|
||||
| `TOKEN` | *(required)* | Your Pulsewise API token |
|
||||
| `HOSTNAME` | System hostname | Override the reported hostname |
|
||||
| `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).
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -79,27 +132,11 @@ # Build and run
|
||||
## Cross-Compiling
|
||||
|
||||
```bash
|
||||
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=amd64 go build -o pulsewise-collector-linux-amd64 .
|
||||
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.
|
||||
|
||||
## 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
|
||||
Or run `./deploy.sh` to bump the version, compile all targets, and publish a release.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -14,3 +14,16 @@ TOKEN=your-token-here
|
||||
|
||||
# Optional — logs full response body from the server
|
||||
# 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
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -24,7 +25,7 @@ import (
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/load"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -60,12 +61,15 @@ const (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Token string
|
||||
Hostname string
|
||||
URL string
|
||||
Interval time.Duration
|
||||
Debug bool
|
||||
AutoUpdate bool
|
||||
Token string
|
||||
Hostname string
|
||||
URL string
|
||||
Interval time.Duration
|
||||
Debug bool
|
||||
AutoUpdate bool
|
||||
FPMStatusURL string
|
||||
NginxStatusURL string
|
||||
CheckPorts []int
|
||||
}
|
||||
|
||||
type Metrics struct {
|
||||
@@ -86,6 +90,9 @@ type Metrics struct {
|
||||
Processes []ProcessInfo `json:"processes"`
|
||||
TCP *TCPStats `json:"tcp,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 {
|
||||
@@ -124,6 +131,35 @@ type FileDescriptors struct {
|
||||
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 {
|
||||
Total uint64 `json:"total"`
|
||||
Available uint64 `json:"available"`
|
||||
@@ -205,6 +241,9 @@ func main() {
|
||||
case "dump":
|
||||
runDumpCommand()
|
||||
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() {
|
||||
if os.Getuid() != 0 {
|
||||
reexecWithSudo()
|
||||
@@ -370,24 +463,24 @@ func runStatusCommand() {
|
||||
fmt.Printf("\n%spulsewise-collector%s v%s\n\n", ansiBold, ansiReset, version)
|
||||
|
||||
// Config
|
||||
token, hostname, url, _, autoUpdate, err := loadConfigFromFile()
|
||||
cfg, err := loadConfigFromFile()
|
||||
if err != nil {
|
||||
fmt.Printf("%s Not configured — %v\n\n", ansiRed, ansiReset)
|
||||
} else {
|
||||
maskedToken := token
|
||||
if len(token) >= 8 {
|
||||
maskedToken = token[:4] + "..." + token[len(token)-4:]
|
||||
maskedToken := cfg.Token
|
||||
if len(cfg.Token) >= 8 {
|
||||
maskedToken = cfg.Token[:4] + "..." + cfg.Token[len(cfg.Token)-4:]
|
||||
} else {
|
||||
maskedToken = "****"
|
||||
}
|
||||
autoUpdateStr := ansiGreen + "enabled" + ansiReset
|
||||
if !autoUpdate {
|
||||
if !cfg.AutoUpdate {
|
||||
autoUpdateStr = ansiDim + "disabled" + 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("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("Auto-update"), autoUpdateStr)
|
||||
fmt.Println()
|
||||
@@ -717,22 +810,14 @@ func execSelf() {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
func loadConfig() *Config {
|
||||
token, hostname, url, debug, autoUpdate, err := loadConfigFromFile()
|
||||
cfg, 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,
|
||||
AutoUpdate: autoUpdate,
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func loadConfigFromFile() (string, string, string, bool, bool, error) {
|
||||
func loadConfigFromFile() (*Config, error) {
|
||||
configPath := configFilePath
|
||||
|
||||
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)
|
||||
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()
|
||||
|
||||
var token, hostname, url string
|
||||
var debug bool
|
||||
autoUpdate := true
|
||||
cfg := &Config{
|
||||
URL: defaultURL,
|
||||
Interval: collectionInterval,
|
||||
AutoUpdate: true,
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
@@ -761,46 +848,48 @@ func loadConfigFromFile() (string, string, string, bool, bool, error) {
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "TOKEN=") {
|
||||
token = strings.TrimPrefix(line, "TOKEN=")
|
||||
token = strings.Trim(token, `"'`)
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
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, `"'`)
|
||||
debug = val == "true" || val == "1"
|
||||
}
|
||||
if strings.HasPrefix(line, "AUTO_UPDATE=") {
|
||||
val := strings.TrimPrefix(line, "AUTO_UPDATE=")
|
||||
val = strings.Trim(val, `"'`)
|
||||
autoUpdate = val != "false" && val != "0"
|
||||
val = strings.Trim(val, `"'`)
|
||||
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 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 == "" {
|
||||
return "", "", "", false, false, fmt.Errorf("TOKEN not found in config file")
|
||||
if cfg.Token == "" {
|
||||
return nil, fmt.Errorf("TOKEN not found in config file")
|
||||
}
|
||||
if hostname == "" {
|
||||
hostname, _ = os.Hostname()
|
||||
if cfg.Hostname == "" {
|
||||
cfg.Hostname, _ = os.Hostname()
|
||||
}
|
||||
|
||||
if url == "" {
|
||||
url = defaultURL
|
||||
}
|
||||
|
||||
return token, hostname, url, debug, autoUpdate, nil
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func collectAndSend(config *Config) {
|
||||
@@ -1037,7 +1126,7 @@ func collectMetrics(config *Config) (*Metrics, error) {
|
||||
}
|
||||
|
||||
// Network (per-second rates)
|
||||
netStats, err := net.IOCounters(false)
|
||||
netStats, err := psnet.IOCounters(false)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to get network stats: %v", err)
|
||||
} else if len(netStats) > 0 {
|
||||
@@ -1086,15 +1175,26 @@ func collectMetrics(config *Config) (*Metrics, error) {
|
||||
metrics.TCP = collectTCPStats()
|
||||
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
|
||||
}
|
||||
|
||||
func collectTCPStats() *TCPStats {
|
||||
conns, err := net.Connections("tcp")
|
||||
conns, err := psnet.Connections("tcp")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
conns6, _ := net.Connections("tcp6")
|
||||
conns6, _ := psnet.Connections("tcp6")
|
||||
conns = append(conns, conns6...)
|
||||
|
||||
stats := &TCPStats{}
|
||||
@@ -1159,6 +1259,130 @@ func collectFileDescriptors() *FileDescriptors {
|
||||
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) {
|
||||
pids, err := process.Pids()
|
||||
if err != nil {
|
||||
|
||||
+29
-1
@@ -114,5 +114,33 @@
|
||||
"cpu_time": 890.12,
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user