Skip to content

Commit 2bc5750

Browse files
committed
feat(relay): report usage and cache tiers from billing-service
1 parent 6d5f62d commit 2bc5750

4 files changed

Lines changed: 203 additions & 0 deletions

File tree

‎docs/BILLING_REALTIME.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Relay ↔ billing-service realtime
2+
3+
Relay stays offline-first, but it knows its tier live and reports what it forwards.
4+
5+
## Live link (env only — `relay.yaml` schema is unchanged)
6+
7+
```bash
8+
BILLING_URL=http://billing:8080
9+
BILLING_API_KEY=<bearer for billing-service /v1>
10+
BILLING_ORG_ID=<org-uuid>
11+
```
12+
13+
- `internal/billing.Cache.Get(ctx)` — cached `GET /v1/entitlements/{org}`
14+
(5 min TTL, fail-open: a billing outage keeps the last known tier and never
15+
blocks ingest).
16+
- `Config.ReportUsage(ctx, events, key)` — `POST /v1/usage` every 60s from the
17+
forwarder with `Idempotency-Key: org:events:YYYYMMDDHHMM` so replays dedupe.
18+
- SSE tail (`GET billing-service /v1/billing/events/stream`) is available for
19+
operators; relay itself polls the cached tier because gateways must survive
20+
disconnects.
21+
22+
## `relay.yaml.example` note
23+
24+
Billing is intentionally env-only so a typo can never silently disable safety
25+
options (the config loader rejects unknown YAML fields). The example file
26+
carries the block below as comments:
27+
28+
```yaml
29+
# Billing realtime (env, not YAML):
30+
# BILLING_URL=http://billing:8080
31+
# BILLING_API_KEY=...
32+
# BILLING_ORG_ID=<org-uuid>
33+
```

‎internal/billing/client.go‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Package billing connects relay to billing-service in realtime.
2+
//
3+
// Relay enforces tiers locally (cached, fail-open) and reports metered usage
4+
// upstream. Env:
5+
//
6+
// BILLING_URL e.g. http://billing:8080 (empty = billing disabled)
7+
// BILLING_API_KEY Bearer key for billing-service /v1
8+
// BILLING_ORG_ID org uuid for usage reports + entitlement lookups
9+
//
10+
// Cadence: FetchTier caches for 5 minutes; ReportUsage is called by the
11+
// forwarder every 60s with an idempotency key of org:events:YYYYMMDDHHMM so
12+
// replays dedupe server-side. A billing outage never blocks ingest — the last
13+
// known tier stays active and usage is retried on the next tick.
14+
package billing
15+
16+
import (
17+
"bytes"
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
"io"
22+
"net/http"
23+
"os"
24+
"strings"
25+
"sync"
26+
"time"
27+
)
28+
29+
// Config for the live billing link.
30+
type Config struct {
31+
URL string
32+
APIKey string
33+
OrgID string
34+
Client *http.Client
35+
}
36+
37+
// ConfigFromEnv loads the link. Enabled() == false means run standalone.
38+
func ConfigFromEnv() Config {
39+
return Config{
40+
URL: strings.TrimRight(strings.TrimSpace(os.Getenv("BILLING_URL")), "/"),
41+
APIKey: strings.TrimSpace(os.Getenv("BILLING_API_KEY")),
42+
OrgID: strings.TrimSpace(os.Getenv("BILLING_ORG_ID")),
43+
Client: &http.Client{Timeout: 10 * time.Second},
44+
}
45+
}
46+
47+
// Enabled reports whether live calls should be attempted.
48+
func (c Config) Enabled() bool { return c.URL != "" && c.APIKey != "" && c.OrgID != "" }
49+
50+
// Tier is the cached entitlement snapshot relay enforces.
51+
type Tier struct {
52+
Plan string `json:"tier"`
53+
MaxDevices int64 `json:"max_devices"`
54+
MaxEventsPerDay int64 `json:"max_events_per_day"`
55+
FetchedAt time.Time
56+
}
57+
58+
// Cache holds the last known tier with a 5 minute TTL.
59+
type Cache struct {
60+
mu sync.RWMutex
61+
cfg Config
62+
tier Tier
63+
}
64+
65+
func NewCache(cfg Config) *Cache { return &Cache{cfg: cfg, tier: Tier{Plan: "local"}} }
66+
67+
// Get returns the cached tier, refreshing in the background when stale.
68+
// Fail-open: any fetch error keeps the previous tier.
69+
func (c *Cache) Get(ctx context.Context) Tier {
70+
c.mu.RLock()
71+
tier, stale := c.tier, time.Since(c.tier.FetchedAt) > 5*time.Minute
72+
c.mu.RUnlock()
73+
if !stale || !c.cfg.Enabled() {
74+
return tier
75+
}
76+
if fresh, err := c.cfg.FetchTier(ctx); err == nil {
77+
c.mu.Lock()
78+
c.tier = fresh
79+
c.mu.Unlock()
80+
return fresh
81+
}
82+
return tier
83+
}
84+
85+
// FetchTier GETs /v1/entitlements/{org} live.
86+
func (c Config) FetchTier(ctx context.Context) (Tier, error) {
87+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.URL+"/v1/entitlements/"+c.OrgID, nil)
88+
if err != nil {
89+
return Tier{Plan: "local"}, err
90+
}
91+
req.Header.Set("Authorization", "Bearer "+c.APIKey)
92+
resp, err := c.Client.Do(req)
93+
if err != nil {
94+
return Tier{Plan: "local"}, err
95+
}
96+
defer resp.Body.Close()
97+
if resp.StatusCode != http.StatusOK {
98+
return Tier{Plan: "local"}, fmt.Errorf("billing: tier fetch HTTP %d", resp.StatusCode)
99+
}
100+
var out Tier
101+
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
102+
return Tier{Plan: "local"}, err
103+
}
104+
if out.Plan == "" {
105+
out.Plan = "pilot"
106+
}
107+
out.FetchedAt = time.Now()
108+
return out, nil
109+
}
110+
111+
// ReportUsage POSTs a meter delta idempotently.
112+
func (c Config) ReportUsage(ctx context.Context, events int64, key string) error {
113+
if !c.Enabled() {
114+
return nil
115+
}
116+
body, _ := json.Marshal(map[string]any{
117+
"organization_id": c.OrgID,
118+
"metrics": map[string]int64{"events": events},
119+
"idempotency_key": key,
120+
})
121+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL+"/v1/usage", bytes.NewReader(body))
122+
if err != nil {
123+
return err
124+
}
125+
req.Header.Set("Content-Type", "application/json")
126+
req.Header.Set("Authorization", "Bearer "+c.APIKey)
127+
if key != "" {
128+
req.Header.Set("Idempotency-Key", key)
129+
}
130+
resp, err := c.Client.Do(req)
131+
if err != nil {
132+
return err
133+
}
134+
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
135+
resp.Body.Close()
136+
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
137+
return fmt.Errorf("billing: usage HTTP %d", resp.StatusCode)
138+
}
139+
return nil
140+
}

‎internal/billing/client_test.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package billing
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
)
8+
9+
func TestCacheDefaultsToLocal(t *testing.T) {
10+
c := NewCache(Config{})
11+
got := c.Get(context.Background())
12+
if got.Plan != "local" {
13+
t.Fatalf("plan = %q, want local", got.Plan)
14+
}
15+
}
16+
17+
func TestDisabledReportIsNoop(t *testing.T) {
18+
c := Config{}
19+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
20+
defer cancel()
21+
if err := c.ReportUsage(ctx, 10, "k"); err != nil {
22+
t.Fatalf("disabled report should be nil, got %v", err)
23+
}
24+
}

‎relay.yaml.example‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,9 @@ privacy:
271271
drop_tlv_types: []
272272
max_payload_bytes: 0
273273
block_encrypted: false
274+
275+
# ─── Billing realtime (env, not YAML — see docs/BILLING_REALTIME.md) ───
276+
# BILLING_URL=http://billing:8080
277+
# BILLING_API_KEY=...
278+
# BILLING_ORG_ID=<org-uuid>
279+

0 commit comments

Comments
 (0)