Show OpenWeather API call usage in the app

OpenWeather reports no remaining quota (only 429 when over), so add an
in-app gauge that counts the calls PilotVault itself makes and shows them
against the plan's per-minute limit.

- Plugin: process-wide usage store bucketed per API key (hashed) into the
  current minute and UTC day; a do() wrapper counts each request that
  reaches OpenWeather (transport errors consume no quota, so uncounted).
  Health and Invoke both route through it.
- New callsPerMinute config field (default 60) that cascades global -> org
  -> user; plugin contract gains plugins.HealthUsage on Health.
- Web App: a "Calls per minute limit" field and an "API call usage" meter
  in the OpenWeather card, shown after Test connection and cached.
- Counts reset on restart and cover only PilotVault's own calls (noted in
  the UI); the account dashboard stays authoritative.
- Tests for per-key counting and minute/day rollover; rebuilt frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-14 11:49:19 +02:00
co-authored by Claude Opus 4.8
parent 285551fe8a
commit 6d73c89cc8
9 changed files with 289 additions and 41 deletions
@@ -29,7 +29,7 @@ const openWeatherPlugin = "openweather"
// owFields are the plugin's ConfigField keys, in display order. Kept in sync with
// the plugin descriptor so the cascade covers every setting.
var owFields = []string{"apiKey", "units", "lat", "lon", "lang"}
var owFields = []string{"apiKey", "units", "lat", "lon", "lang", "callsPerMinute"}
// owSecretKeys are masked in every view and preserved on save when left at the mask.
var owSecretKeys = map[string]bool{"apiKey": true}
@@ -37,11 +37,12 @@ var owSecretKeys = map[string]bool{"apiKey": true}
// owConfig is one layer's openweather settings. Values are strings to match the
// plugin's ConfigField keys 1:1 (they are handed straight to plugins.Init).
type owConfig struct {
APIKey string `json:"apiKey"`
Units string `json:"units"`
Lat string `json:"lat"`
Lon string `json:"lon"`
Lang string `json:"lang"`
APIKey string `json:"apiKey"`
Units string `json:"units"`
Lat string `json:"lat"`
Lon string `json:"lon"`
Lang string `json:"lang"`
CallsPerMinute string `json:"callsPerMinute"`
}
// owStored is what we persist per user/org under pluginSettings.openweather.
@@ -74,7 +75,7 @@ type owResolution struct {
// owConfigFromMap builds an owConfig from a flat string map (global plugin config).
func owConfigFromMap(m map[string]string) owConfig {
return owConfig{APIKey: m["apiKey"], Units: m["units"], Lat: m["lat"], Lon: m["lon"], Lang: m["lang"]}
return owConfig{APIKey: m["apiKey"], Units: m["units"], Lat: m["lat"], Lon: m["lon"], Lang: m["lang"], CallsPerMinute: m["callsPerMinute"]}
}
// owGet returns a config field by the plugin's key name.
@@ -90,6 +91,8 @@ func owGet(c owConfig, key string) string {
return c.Lon
case "lang":
return c.Lang
case "callsPerMinute":
return c.CallsPerMinute
}
return ""
}
@@ -107,6 +110,8 @@ func owSet(c *owConfig, key, v string) {
c.Lon = v
case "lang":
c.Lang = v
case "callsPerMinute":
c.CallsPerMinute = v
}
}
@@ -8,6 +8,8 @@ package openweather
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -22,6 +24,77 @@ import (
"pilotvault/apiserver/internal/plugins"
)
// defaultCallsPerMinute is OpenWeather's free-tier per-minute limit, used to gauge
// usage when no plan limit is configured.
const defaultCallsPerMinute = 60
// callUsage tracks how many upstream OpenWeather calls this process has made for
// one API key, bucketed into the current minute and the current UTC day.
//
// OpenWeather does not report remaining quota, so this counts only the calls
// PilotVault itself makes (not any made outside it) and resets when the server
// restarts — an approximate gauge, not an authoritative balance.
type callUsage struct {
mu sync.Mutex
minuteStart time.Time
minuteCount int
dayStart time.Time
dayCount int
}
// roll resets the minute/day buckets that now has moved past. Caller holds u.mu.
func (u *callUsage) roll(now time.Time) {
if m := now.Truncate(time.Minute); !m.Equal(u.minuteStart) {
u.minuteStart, u.minuteCount = m, 0
}
if d := now.UTC().Truncate(24 * time.Hour); !d.Equal(u.dayStart) {
u.dayStart, u.dayCount = d, 0
}
}
func (u *callUsage) record(now time.Time) {
u.mu.Lock()
defer u.mu.Unlock()
u.roll(now)
u.minuteCount++
u.dayCount++
}
func (u *callUsage) snapshot(now time.Time) (minute, day int) {
u.mu.Lock()
defer u.mu.Unlock()
u.roll(now)
return u.minuteCount, u.dayCount
}
// usageStore holds per-API-key call counters for the whole process, so counts
// survive the transient plugin instances the settings cascade builds per request.
type usageStore struct {
mu sync.Mutex
m map[string]*callUsage
}
var usage = &usageStore{m: map[string]*callUsage{}}
func (s *usageStore) get(keyHash string) *callUsage {
s.mu.Lock()
defer s.mu.Unlock()
u := s.m[keyHash]
if u == nil {
u = &callUsage{}
s.m[keyHash] = u
}
return u
}
// keyFingerprint derives a short, non-reversible bucket id from an API key, so
// distinct keys (e.g. per-organization) get independent usage counters without
// the key itself ever being stored in the counter map.
func keyFingerprint(apiKey string) string {
sum := sha256.Sum256([]byte(apiKey))
return hex.EncodeToString(sum[:8])
}
// apiBase is the OpenWeather REST root. It is copied into the plugin instance in
// Init so tests can point the connector at an httptest server.
const apiBase = "https://api.openweathermap.org"
@@ -39,12 +112,14 @@ func init() {
// Plugin is the OpenWeather connector.
type Plugin struct {
mu sync.Mutex
apiKey string
units string
lang string
lat string
lon string
mu sync.Mutex
apiKey string
units string
lang string
lat string
lon string
callsPerMinute int
keyHash string // fingerprint of apiKey, keys this plugin's usage counter
base string
client *http.Client
@@ -85,10 +160,20 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
Help: "Longitude used by the health probe and by calls that supply no location (180…180)."},
{Key: "lang", Label: "Language", Type: "text",
Help: "Optional ISO language code for human-readable weather descriptions (e.g. en, pl, de). Leave blank for the API default."},
{Key: "callsPerMinute", Label: "Calls per minute limit", Type: "number", Default: "60",
Help: "Your OpenWeather plan's per-minute call limit (the free tier is 60). Used only to gauge usage in the app — OpenWeather does not report remaining quota, so the app counts the calls it makes against this number."},
},
}
}
// atoiDefault parses s as an int, returning def when it is blank or malformed.
func atoiDefault(s string, def int) int {
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && n > 0 {
return n
}
return def
}
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
p.mu.Lock()
defer p.mu.Unlock()
@@ -106,11 +191,27 @@ func (p *Plugin) Init(_ context.Context, config map[string]string) error {
if p.lon == "" {
p.lon = defaultLon
}
p.callsPerMinute = atoiDefault(config["callsPerMinute"], defaultCallsPerMinute)
p.keyHash = keyFingerprint(p.apiKey)
p.base = apiBase
p.client = &http.Client{Timeout: 10 * time.Second}
return nil
}
// do performs an upstream request and records it against this key's usage gauge
// when the request actually reached OpenWeather (any HTTP response — a transport
// failure consumes no quota, so it is not counted).
func (p *Plugin) do(req *http.Request) (*http.Response, error) {
resp, err := p.client.Do(req)
if err == nil {
p.mu.Lock()
kh := p.keyHash
p.mu.Unlock()
usage.get(kh).record(time.Now())
}
return resp, err
}
// requestURL builds an absolute OpenWeather request URL for the given path,
// merging the caller's query with the configured units, language and API key.
// The API key is never included when unset (callers must guard on errNoKey).
@@ -164,7 +265,7 @@ func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
target := p.requestURL("/data/2.5/weather", p.pointQuery("", ""))
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
resp, err := p.client.Do(req)
resp, err := p.do(req)
lat := time.Since(start).Milliseconds()
if err != nil {
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
@@ -186,9 +287,27 @@ func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
default:
h.Status, h.Detail = plugins.StatusDown, "HTTP "+resp.Status
}
// Attach the app-side usage gauge (this probe is included in the count) so the
// UI can show consumption against the plan's per-minute limit.
p.mu.Lock()
kh, limit := p.keyHash, p.callsPerMinute
p.mu.Unlock()
minute, day := usage.get(kh).snapshot(time.Now())
h.Usage = &plugins.HealthUsage{MinuteUsed: minute, MinuteLimit: limit, DayUsed: day}
h.Detail += " · " + usageText(minute, limit, day)
return h
}
// usageText renders a compact app-usage summary, e.g. "3/60 calls this min · 27 today".
func usageText(minute, limit, day int) string {
m := strconv.Itoa(minute)
if limit > 0 {
m += "/" + strconv.Itoa(limit)
}
return m + " calls this min · " + strconv.Itoa(day) + " today"
}
// currentSummary renders a short "place: 12°C, clear sky" line from a current
// weather payload. Returns "" when the body can't be parsed.
func currentSummary(data []byte) string {
@@ -253,7 +372,7 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
target := p.requestURL(path, p.pointQuery(in.Lat, in.Lon))
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
resp, err := p.client.Do(req)
resp, err := p.do(req)
if err != nil {
return nil, err
}
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"pilotvault/apiserver/internal/plugins"
)
@@ -69,6 +70,9 @@ func TestInitDefaults(t *testing.T) {
if p.lat != defaultLat || p.lon != defaultLon {
t.Errorf("default location = %q,%q, want %q,%q", p.lat, p.lon, defaultLat, defaultLon)
}
if p.callsPerMinute != defaultCallsPerMinute {
t.Errorf("default callsPerMinute = %d, want %d", p.callsPerMinute, defaultCallsPerMinute)
}
if p.client == nil {
t.Error("Init must build an http client")
}
@@ -205,3 +209,56 @@ func TestInvokeNoKey(t *testing.T) {
t.Error("expected errNoKey when no API key is configured")
}
}
// TestUsageCounter confirms each upstream call is counted and surfaced on Health,
// bucketed per API key against the configured per-minute limit.
func TestUsageCounter(t *testing.T) {
srv := newFakeOW(t)
defer srv.Close()
p := &Plugin{}
// A key unique to this test so its counter is isolated from other tests that
// share the process-wide usage store. The fake returns 401 for a non-"good-key",
// but a call that reaches the server is still counted (only transport errors are not).
_ = p.Init(context.Background(), map[string]string{"apiKey": "usage-key-" + t.Name(), "callsPerMinute": "42"})
p.base = srv.URL
ctx := context.Background()
h := p.HealthCheck(ctx)
if h.Usage == nil {
t.Fatal("expected Usage on the health result")
}
if h.Usage.MinuteLimit != 42 {
t.Errorf("MinuteLimit = %d, want the configured 42", h.Usage.MinuteLimit)
}
if h.Usage.MinuteUsed != 1 || h.Usage.DayUsed != 1 {
t.Errorf("after 1 probe usage = %d/min %d/day, want 1/1", h.Usage.MinuteUsed, h.Usage.DayUsed)
}
// A second probe and an Invoke both add to the same bucket.
_ = p.HealthCheck(ctx)
_, _ = p.Invoke(ctx, "weather.current", nil)
h3 := p.HealthCheck(ctx)
if h3.Usage.MinuteUsed != 4 || h3.Usage.DayUsed != 4 {
t.Errorf("after 4 calls usage = %d/min %d/day, want 4/4", h3.Usage.MinuteUsed, h3.Usage.DayUsed)
}
}
// TestUsageRollover confirms the minute bucket resets when the clock moves on.
func TestUsageRollover(t *testing.T) {
u := &callUsage{}
base := time.Date(2026, 7, 14, 10, 30, 0, 0, time.UTC)
u.record(base)
u.record(base.Add(30 * time.Second)) // same minute
if m, d := u.snapshot(base.Add(45 * time.Second)); m != 2 || d != 2 {
t.Fatalf("same-minute snapshot = %d/%d, want 2/2", m, d)
}
// Next minute: minute count resets, day count persists.
if m, d := u.snapshot(base.Add(90 * time.Second)); m != 0 || d != 2 {
t.Errorf("next-minute snapshot = %d/%d, want 0/2", m, d)
}
// Next day: both reset.
if m, d := u.snapshot(base.Add(25 * time.Hour)); m != 0 || d != 0 {
t.Errorf("next-day snapshot = %d/%d, want 0/0", m, d)
}
}
+12
View File
@@ -113,6 +113,18 @@ type Health struct {
LatencyMs int64 `json:"latencyMs,omitempty"`
Detail string `json:"detail,omitempty"`
Credits *HealthCredits `json:"credits,omitempty"`
Usage *HealthUsage `json:"usage,omitempty"`
}
// HealthUsage is optional call-usage accounting a plugin may report when its
// upstream does NOT expose remaining quota (e.g. OpenWeather). Unlike
// HealthCredits — which reflects a balance the upstream reports — these are
// process-local counts of the calls this server has made, bucketed into the
// current minute and day, so the UI can render an approximate usage gauge.
type HealthUsage struct {
MinuteUsed int `json:"minuteUsed"` // calls made in the current minute
MinuteLimit int `json:"minuteLimit,omitempty"` // the plan's per-minute limit
DayUsed int `json:"dayUsed"` // calls made so far today (UTC)
}
// HealthCredits is optional structured rate-limit/credit accounting a plugin may