Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ node_modules/
web/node_modules/
web/.vite/
web/dist/*
web/test-results/
web/playwright-report/
!web/dist/.gitkeep
web-v2/

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ RUN npm ci
COPY web/ ./
RUN npm run build

FROM golang:1.22-alpine AS go-builder
FROM golang:1.25-alpine AS go-builder
WORKDIR /app
RUN apk add --no-cache build-base
COPY go.mod go.sum ./
Expand Down
29 changes: 29 additions & 0 deletions docs/adr/0006-time-zone-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Store UTC, aggregate day buckets in the business timezone

Status: current

CPA Usage stores timestamps and rollup buckets in UTC and applies the business timezone (`TZ`, default `Asia/Shanghai`) only at the boundaries where a calendar day meaning matters: day-granularity aggregation, Today/Yesterday windows, daily maintenance triggers, and log-file rotation. Mixing UTC and localtime at the wrong layer produces silently shifted analytics, so this decision fixes the contract for every layer.

## Decision

- Store event timestamps in UTC semantics: `usage_events.timestamp` is a `time.Time` written and read as UTC; any provider timestamp is normalized to UTC before persistence.
- Key hourly rollups by UTC-aligned hour buckets: `usage_rollups_hourly.bucket_start` is `event.Timestamp.UTC().Truncate(time.Hour)` and never a business-timezone hour.
- Apply `strftime(..., 'localtime')` only for day-granularity aggregation, on both the raw events source and the rollup source. `'localtime'` resolves against the process `time.Local`, which is set from the `TZ` configuration (embedded `time/tzdata`, default `Asia/Shanghai`).
- Hour-granularity buckets stay UTC-aligned (`%Y-%m-%dT%H:00:00Z` for the raw fallback path; rollup buckets are already UTC).
- Compute Today/Yesterday analysis windows as business-timezone calendar-day boundaries (`time.Local` day start/end) and convert the resulting boundaries to UTC before querying. Rolling windows (`24h`, `7d`, `30d`) anchor on `time.Now().UTC()`.
- Keep all SQL filter boundaries in UTC (`StartTime.UTC()` / `EndTime.UTC()`); never compare localtime strings against stored timestamps.
- Use the business timezone for operational scheduling that is calendar-relative: daily storage cleanup and database backup at fixed local times, and daily log-file rotation.
- Backfill and rollup maintenance progress in UTC hour buckets: `target_bucket_start` and `covered_bucket_start` are UTC-aligned.
- Do not convert stored UTC values into a different timezone at the presentation boundary; the frontend formats UTC instants into the operator's locale without shifting the underlying instant.

## Consequences

- Day-granularity analytics follow the configured business timezone while hour-granularity analytics and rollups remain UTC-aligned, giving operators stable daily boundaries without drifting hour buckets across deployments with different `TZ` values.
- A deployment that changes `TZ` re-reads historical daily aggregation under the new calendar-day boundaries while hourly rollup coverage remains unchanged; operators must treat day-level history as timezone-dependent.
- Raw fallback day buckets and rollup day buckets use the same localtime expression, so `backfill_incomplete` fallback results remain consistent with rollup results.
- New analytics SQL must route through the shared bucket expression helpers instead of writing ad-hoc `strftime` calls, so the UTC/localtime split stays in one place.
- The `TZ` environment variable now has a documented dual role: calendar-day analytics boundaries and calendar-relative operational scheduling.

## Compatibility

This decision does not change external behavior for existing deployments: the current implementation already stores UTC and aggregates day buckets with `'localtime'` under the `TZ` configuration. The ADR fixes the contract so future changes (new breakdowns, new windows, or read-model refactors) preserve the split instead of regressing to mixed timezone handling.
24 changes: 24 additions & 0 deletions docs/adr/0007-public-metrics-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Expose a public runtime metrics endpoint

CPA Usage exposes `GET /metrics` next to `/healthz` as an unauthenticated runtime snapshot endpoint. The endpoint returns aggregated numbers and status strings about queue consumption, rollup backfill, database backup, and process uptime, without request detail or identity information.

## Decision

- Serve `/metrics` on the app base path, outside the auth-protected `/api/v1` group, at the same visibility level as `/healthz`.
- The snapshot contains only aggregate counters, boolean runner states, status strings, and timestamps. It never includes raw usage events, API keys, aliases, or per-identity rows, so it carries no data that the protected workspace protects.
- `redis_inbox_pending` counts `pending` plus `process_failed` inbox rows, matching the retryable backlog definition used by the process loop.
- Redis inbox processing throughput is exposed as cumulative counters (`redis_events_processed_total`, `redis_events_processed_batches_total`, `redis_events_last_processed_at`) plus a derived `redis_events_processing_rate_per_minute` computed from the delta between adjacent snapshot requests. Failed batches and empty batches (no rows to process) are not counted, so retries and idle polling do not inflate throughput or keep `redis_events_last_processed_at` advancing while idle.
- Snapshot values are read on demand from runner state and lightweight repository queries rather than from pre-computed expvar counters, keeping the endpoint always consistent with the current process state. The per-request cost is two indexed COUNT/SELECT queries at most.
- If either database-backed read fails, the endpoint still returns the process-local portion of the snapshot and sets `db_unavailable: true`; the endpoint itself only returns an error when the metrics provider is absent or the process state is unreachable, so a database outage degrades the snapshot instead of hiding process health.
- Boolean runner states use JSON booleans (`poller_running`), matching the shape of the protected `/api/v1/status` response rather than introducing a second boolean encoding.
- Operators who want to hide the endpoint entirely can place the service behind a reverse proxy with basic auth or path rules; the service itself does not add a second auth surface.

## Consequences

- Monitoring systems and container probes can scrape runtime health without a session, including the Docker HEALTHCHECK pattern already used for `/healthz`.
- The endpoint duplicates a subset of `/api/v1/status` information; that duplication is accepted because the protected status response remains the operator-facing workspace surface, while `/metrics` is the machine-readable scrape surface.
- Snapshot-based rate derivation requires a scrape interval; a single request after a long idle period reports a rate over the full idle span, and the first request after startup reports no rate until the second sample.

## Compatibility

This is an additive endpoint decision. Existing API contracts, auth behavior, the protected status surface, ingestion semantics, and deployment topology remain compatible. The endpoint is new externally observable behavior and is deliberately unauthenticated; deployments that require authenticated access must enforce it at the reverse proxy.
15 changes: 7 additions & 8 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
module cpa-usage

go 1.22
go 1.25.0

require (
github.com/gin-gonic/gin v1.10.0
github.com/joho/godotenv v1.5.1
github.com/sirupsen/logrus v1.9.3
github.com/mattn/go-sqlite3 v1.14.22
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
Expand All @@ -27,17 +27,16 @@ require (
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
golang.org/x/arch v0.29.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
23 changes: 10 additions & 13 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
Expand All @@ -74,19 +72,18 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho=
golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
Expand Down
28 changes: 28 additions & 0 deletions internal/api/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package api

import (
"context"
"net/http"

"github.com/gin-gonic/gin"
)

// MetricsProvider 是运行时快照的 HTTP 层入口 seam;
// 实现由 app 组装各后台 runner 与 repository 读模型后提供。
type MetricsProvider interface {
MetricsSnapshot(context.Context) (map[string]any, error)
}

func registerMetricsRoute(router gin.IRoutes, provider MetricsProvider) {
if provider == nil {
return
}
router.GET("/metrics", func(c *gin.Context) {
snapshot, err := provider.MetricsSnapshot(c.Request.Context())
if err != nil {
writeInternalError(c, "metrics snapshot is unavailable", err)
return
}
c.JSON(http.StatusOK, snapshot)
})
}
74 changes: 74 additions & 0 deletions internal/api/metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package api

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
)

type metricsStub struct {
snapshot map[string]any
err error
}

func (s metricsStub) MetricsSnapshot(context.Context) (map[string]any, error) {
return s.snapshot, s.err
}

func TestMetricsRouteServesRuntimeSnapshot(t *testing.T) {
router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "", OptionalProviders{
Metrics: metricsStub{snapshot: map[string]any{
"uptime_seconds": int64(42),
"redis_inbox_pending": int64(3),
}},
})

req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

if recorder.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", recorder.Code)
}
var body map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode metrics body: %v", err)
}
if body["uptime_seconds"] != float64(42) {
t.Fatalf("expected uptime_seconds 42, got %v", body["uptime_seconds"])
}
if body["redis_inbox_pending"] != float64(3) {
t.Fatalf("expected redis_inbox_pending 3, got %v", body["redis_inbox_pending"])
}
}

func TestMetricsRouteUnavailableWithoutProvider(t *testing.T) {
router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "", OptionalProviders{})

req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

if recorder.Code != http.StatusNotFound {
t.Fatalf("expected 404 without metrics provider, got %d", recorder.Code)
}
}

func TestMetricsRouteReportsProviderError(t *testing.T) {
router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "", OptionalProviders{
Metrics: metricsStub{err: context.DeadlineExceeded},
})
gin.SetMode(gin.TestMode)

req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)

if recorder.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", recorder.Code)
}
}
2 changes: 2 additions & 0 deletions internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ type OptionalProviders struct {
KeyAlias service.KeyAliasProvider
Quota QuotaProvider
RollupBackfill RollupBackfillStatusProvider
Metrics MetricsProvider
}

type syncUserMessageError interface {
Expand All @@ -104,6 +105,7 @@ func NewRouter(

appGroup := router.Group(basePath)
registerHealthRoutes(appGroup)
registerMetricsRoute(appGroup, optionalProviders.Metrics)

apiV1 := appGroup.Group("/api/v1")
apiV1.GET("/ping", func(c *gin.Context) {
Expand Down
Loading
Loading