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:
co-authored by
Claude Opus 4.8
parent
285551fe8a
commit
6d73c89cc8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user