Stage 16: deploy infra & test contour
- backend + gateway multi-stage distroless Dockerfiles; the gateway embeds and
serves the SPA at / and /telegram/ via go:embed (committed dist placeholder,
real build baked in by the image's node stage)
- deploy/docker-compose.yml: backend + gateway + Postgres + Telegram connector
(VPN sidecar) + OTel Collector + Prometheus (15d) + Tempo (72h) + Grafana,
fronted by a caddy owning a single /_gm Basic-Auth (admin console + Grafana
subpath); inter-service on a private network, only caddy on the edge network
- new metrics: backend accounts_created_total{kind} (robots excluded) and an
in-memory gateway active_users{window=24h,7d} gauge
- CI: single .gitea/workflows/ci.yaml (unit/integration/ui + a gated test-contour
deploy) on the new feature/* -> development -> master branch model; the old
go-unit/integration/ui-test workflows are folded in; the connector-scoped
compose is retired (superseded by deploy/)
- docs: ARCHITECTURE §11/§12/§13, root + gateway READMEs, CLAUDE.md branching,
PLAN.md (stage 16 done + refinements + Stage 17 forward-notes)
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
package connectsrv
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// activeUsers tracks distinct authenticated accounts by last-action time, backing
|
||||
// the in-memory active_users gauge. It is single-process by design (the gateway is
|
||||
// single-instance in the MVP, docs/ARCHITECTURE.md §10): the distinct count is
|
||||
// correct for one process, resets on restart, and is a live operational gauge, not
|
||||
// a billing figure. Memory is bounded by the number of distinct accounts active
|
||||
// within the longest window; stale entries are pruned on observation.
|
||||
type activeUsers struct {
|
||||
mu sync.Mutex
|
||||
lastSeen map[string]time.Time
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// newActiveUsers returns an empty tracker using the wall clock.
|
||||
func newActiveUsers() *activeUsers {
|
||||
return &activeUsers{lastSeen: make(map[string]time.Time), now: time.Now}
|
||||
}
|
||||
|
||||
// seen records that account uid performed an authenticated action now.
|
||||
func (a *activeUsers) seen(uid string) {
|
||||
if uid == "" {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.lastSeen[uid] = a.now()
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// counts returns, for each window, the number of distinct accounts last seen
|
||||
// within it, pruning entries older than the longest window in the same pass.
|
||||
func (a *activeUsers) counts(windows []time.Duration) []int {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
now := a.now()
|
||||
var longest time.Duration
|
||||
for _, w := range windows {
|
||||
if w > longest {
|
||||
longest = w
|
||||
}
|
||||
}
|
||||
|
||||
res := make([]int, len(windows))
|
||||
for uid, ts := range a.lastSeen {
|
||||
age := now.Sub(ts)
|
||||
if age > longest {
|
||||
delete(a.lastSeen, uid)
|
||||
continue
|
||||
}
|
||||
for i, w := range windows {
|
||||
if age <= w {
|
||||
res[i]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
Reference in New Issue
Block a user