A fault-tolerant cost monitoring system for DigitalOcean droplets using Wippy's actor model.
- Periodic Polling: Fetches droplet data from DigitalOcean API every 5 minutes
- Cost Calculation: Computes monthly, daily, and hourly costs with detailed breakdown
- Data Persistence: Stores cost history in SQLite database
- HTTP API: REST endpoints to query current costs via
http.endpoint - Threshold Alerting: Logs alerts when costs exceed configurable threshold
- Fault Tolerance: Supervisor actor automatically restarts failed children
- Actor Model: Clean separation of concerns via message passing
Edit .env and add your DigitalOcean API token:
# Get your token from: https://cloud.digitalocean.com/account/api/tokens
DO_API_TOKEN=your_actual_token_here
DO_COST_THRESHOLD=100.00Important: Never commit .env to version control! It's already in .gitignore.
wippy initwippy run butschster/domonitorThe supervisor will start all actors and begin monitoring.
The application exposes a REST API for querying cost data.
http://localhost:8090/api/v1/domonitor
Returns the current calculated costs from memory store.
Entry: app.domonitor:endpoint_current_costs
Request:
curl http://localhost:8090/api/v1/domonitor/costs/currentResponse (200 OK):
{
"data": {
"total_monthly": 150.00,
"total_daily": 5.00,
"total_hourly": 0.21,
"droplet_count": 3,
"droplets": [
...
]
},
"meta": {
"source": "memory_store",
"cached": true
}
}Response (404 Not Found):
{
"error": "Not found",
"message": "No cost data available yet. Wait for the next polling cycle."
}Response (503 Service Unavailable):
{
"error": "Service unavailable",
"message": "Failed to connect to state store"
}Supervisor (do_supervisor)
│
├── Persister (do_persister) ──→ SQLite DB + Memory Store
│
├── Calculator (do_calculator)
│ │
│ └── costs_calculated ──→ Persister, Alerter
│
├── Alerter (do_alerter) ──→ Log Alerts
│
└── Poller (do_poller)
│
└── droplets_fetched ──→ Calculator, Persister
The project uses Wippy's entry registry system to define all components declaratively in _index.yaml files. Entries
are identified by namespace:name format (e.g., app.domonitor:poller).
| Kind | Description | Example Entry |
|---|---|---|
ns.definition |
Namespace metadata | app.domonitor:definition |
env.variable |
Environment variable reference | app.domonitor:do_api_token |
env.storage.file |
File-based env storage (.env) | app:env |
env.storage.router |
Routes env reads across storages | app:env_router |
db.sql.sqlite |
SQLite database connection | app:db |
store.memory |
In-memory key-value store | app:state_store |
http.service |
HTTP server binding to port | app:gateway |
http.router |
Route prefix + middleware | app.domonitor:api |
http.endpoint |
Individual HTTP endpoint | app.domonitor:endpoint_current_costs |
function.lua |
Reusable Lua function | app.domonitor:fetch_droplets |
process.lua |
Actor process definition | app.domonitor:poller |
process.host |
Process execution host | app.domonitor:processes |
process.service |
Managed service with lifecycle | app.domonitor:supervisor_service |
src/_index.yaml- Shared infrastructure (database, store, HTTP gateway)src/domonitor/_index.yaml- Application entries (actors, functions, endpoints)src/domonitor/migrations/_index.yaml- Database migration entries
Use registry.find() to query entries programmatically:
-- Find all Lua functions
local entries, err = registry.find({kind = "function.lua"})
-- Find endpoints in a namespace
local entries, err = registry.find({kind = "http.endpoint", namespace = "app.domonitor"})The project supports two ways to configure environment variables:
.envfile (Recommended for development)- OS environment variables (Recommended for production)
The .env file takes priority over OS environment variables.
Create a .env file in the project root:
# DigitalOcean API Configuration
DO_API_TOKEN=your_digitalocean_token_here
# Cost Alerting
DO_COST_THRESHOLD=100.00export DO_API_TOKEN="your_digitalocean_api_token"
export DO_COST_THRESHOLD="100"| Variable | Required | Default | Description |
|---|---|---|---|
DO_API_TOKEN |
Yes | - | DigitalOcean API token |
DO_COST_THRESHOLD |
No | 100.00 | Monthly cost alert threshold ($) |
The project uses a router pattern for environment storage defined in src/_index.yaml:
# File-based environment storage
- name: env
kind: env.storage.file
file_path: ".env"
auto_create: true
# Router reads from file storage
- name: env_router
kind: env.storage.router
storages:
- app:env # .env fileEnvironment variables are accessed via entries in src/domonitor/_index.yaml:
- name: do_api_token
kind: env.variable
storage: app:env_router
variable: DO_API_TOKEN- Reads: Routes through
app:env_routertoapp:env(file storage) - Writes: Writes to
.envfile viaenv.set() - Security:
.envfile should have restricted permissions
| File | Constant | Default | Description |
|---|---|---|---|
actors/poller.lua |
POLL_INTERVAL |
5m | API polling interval |
actors/alerter.lua |
ALERT_COOLDOWN |
3600 | Seconds between alerts |
actors/supervisor.lua |
HEALTH_CHECK_INTERVAL |
30s | Health check frequency |
The recommended way to access current cost data:
curl http://localhost:8090/api/v1/domonitor/costs/currentAccess the current_costs key directly in code:
local store = require("store")
local mem_store = store.get("app:state_store")
local costs_json = mem_store:get("current_costs")The database is configured in src/_index.yaml as app:db. By default it uses :memory: (in-memory SQLite). For
persistent storage, change the file property:
- name: db
kind: db.sql.sqlite
file: "data/domonitor.db" # File-based storageThen query with:
sqlite3 data/domonitor.db "SELECT * FROM cost_history ORDER BY id DESC LIMIT 1;"src/
├── _index.yaml # Shared infrastructure entries (app namespace)
│ # - app:env (env.storage.file)
│ # - app:env_router (env.storage.router)
│ # - app:db (db.sql.sqlite)
│ # - app:state_store (store.memory)
│ # - app:gateway (http.service on :8090)
│
└── domonitor/
├── _index.yaml # Application entries (app.domonitor namespace)
│ # HTTP: api router, endpoint_current_costs
│ # Functions: fetch_droplets, calculate_*
│ # Actors: poller, calculator, persister, alerter, supervisor
│ # Services: *_service with lifecycle management
│
├── wippy.yaml # Module manifest
├── .env # Environment variables (gitignored)
├── README.md
│
├── sdk/
│ └── digitalocean.lua # DO API client (fetch_droplets, fetch_droplet)
│
├── calc/
│ └── costs.lua # Cost calculations (calculate_monthly_cost, calculate_daily_cost)
│
├── handlers/
│ └── costs.lua # HTTP handler for GET /costs/current
│
├── actors/
│ ├── supervisor.lua # Root supervisor - manages actor lifecycle
│ ├── poller.lua # Periodic API polling (5 min intervals)
│ ├── calculator.lua # Stateless cost computation
│ ├── persister.lua # DB + store writes
│ └── alerter.lua # Threshold-based notifications
│
└── migrations/
├── _index.yaml # Migration entries
└── 001_create_cost_history.lua
- Verify
wippy.lockis current:wippy init - Check environment variables are set
- Review logs for error messages
- Ensure migration ran: check for
cost_historytable - Verify DO API token is valid
- Check network connectivity to DigitalOcean API
- Verify costs exceed threshold
- Check cooldown period (default 1 hour)
- Review alerter logs for "suppressed by cooldown" messages
- Supervisor logs health checks every 30 seconds
- Check for "missing children detected" warnings
- Review restart counts in health check logs
Edit calc/costs.lua and add to PRICING_TABLE:
local PRICING_TABLE = {
-- ... existing entries
["new-size-slug"] = 99.00,
}The alerter logs structured warnings. To add webhooks or other notifications, modify actors/alerter.lua.
Send a message to the supervisor:
process.send(supervisor_pid, "restart_child", {name = "poller"})MIT