8700fbfae1
- 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)
64 lines
1.6 KiB
Go
64 lines
1.6 KiB
Go
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
|
|
}
|