Files
multica/server/internal/handler/client_usage.go
Multica Eve 216aee5629 [MUL-5125] Add daily Desktop/Web usage and runtime reporting (#5763)
* feat(analytics): add daily client usage reporting (MUL-5125)

Co-authored-by: multica-agent <github@multica.ai>

* fix(analytics): clarify daily usage semantics (MUL-5125)

Co-authored-by: multica-agent <github@multica.ai>

* fix(analytics): resolve usage review blockers (MUL-5125)

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-22 16:52:34 +08:00

228 lines
7.9 KiB
Go

package handler
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"regexp"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/middleware"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
const clientUsageBodyLimit = 16 * 1024
var (
providerNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
clientVersionPattern = regexp.MustCompile(`^[\x20-\x7e]{1,64}$`)
)
type clientUsageRequest struct {
InstallID string `json:"install_id"`
Runtime *clientUsageRuntimeProbe `json:"runtime,omitempty"`
}
type clientUsageRuntimeProbe struct {
ProbeResult string `json:"probe_result"`
RuntimeCount *int32 `json:"runtime_count,omitempty"`
ProviderSummary map[string]int `json:"provider_summary,omitempty"`
OnlineCount *int32 `json:"online_count,omitempty"`
OfflineCount *int32 `json:"offline_count,omitempty"`
}
type validatedRuntimeProbe struct {
Result pgtype.Text
RuntimeCount pgtype.Int4
ProviderSummary []byte
OnlineCount pgtype.Int4
OfflineCount pgtype.Int4
}
// UpsertClientUsage records at most one row per user, client installation, and
// UTC day. Repeated reports refresh the same row; an activity-only report never
// clears a runtime snapshot already collected earlier that day.
func (h *Handler) UpsertClientUsage(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
userUUID, ok := parseUUIDOrBadRequest(w, userID, "user id")
if !ok {
return
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, clientUsageBodyLimit))
decoder.DisallowUnknownFields()
var req clientUsageRequest
if err := decoder.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
installID, ok := parseUUIDOrBadRequest(w, strings.TrimSpace(req.InstallID), "install_id")
if !ok {
return
}
clientType, clientVersion, clientOS := middleware.ClientMetadataFromContext(r.Context())
clientType = strings.ToLower(strings.TrimSpace(clientType))
if clientType != "web" && clientType != "desktop" {
writeError(w, http.StatusBadRequest, "client platform must be web or desktop")
return
}
clientVersion = strings.TrimSpace(clientVersion)
if clientVersion == "" {
clientVersion = "unknown"
}
if !clientVersionPattern.MatchString(clientVersion) {
writeError(w, http.StatusBadRequest, "invalid client version")
return
}
clientOS = normalizeClientUsageOS(clientOS)
var runtime validatedRuntimeProbe
if req.Runtime != nil {
if clientType != "desktop" {
writeError(w, http.StatusBadRequest, "runtime data is only accepted from desktop")
return
}
var err error
runtime, err = validateClientUsageRuntime(*req.Runtime)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
workspaceUUID := pgtype.UUID{}
queries := h.Queries
var tx pgx.Tx
if workspaceID := h.resolveWorkspaceID(r); workspaceID != "" {
workspaceUUID, ok = parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
var err error
tx, err = h.TxStarter.Begin(r.Context())
if err != nil {
slog.Error("failed to begin client usage transaction", "error", err)
writeError(w, http.StatusInternalServerError, "failed to record client usage")
return
}
defer tx.Rollback(r.Context())
queries = h.Queries.WithTx(tx)
// Share the explicit workspace delete/create lock protocol: if deletion
// wins, the row is gone and this report fails; if reporting wins, deletion
// waits and clears the context after the upsert commits.
if _, err := queries.LockWorkspaceForChatSessionCreate(r.Context(), workspaceUUID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusForbidden, "workspace not found")
return
}
slog.Error("failed to lock client usage workspace", "error", err)
writeError(w, http.StatusInternalServerError, "failed to record client usage")
return
}
if _, err := queries.GetMemberByUserAndWorkspace(r.Context(), db.GetMemberByUserAndWorkspaceParams{
UserID: userUUID, WorkspaceID: workspaceUUID,
}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusForbidden, "workspace not found")
return
}
slog.Error("failed to validate client usage workspace", "error", err)
writeError(w, http.StatusInternalServerError, "failed to record client usage")
return
}
}
if _, err := queries.UpsertClientUsageDaily(r.Context(), db.UpsertClientUsageDailyParams{
UserID: userUUID,
ClientType: clientType,
InstallID: installID,
WorkspaceID: workspaceUUID,
ClientVersion: clientVersion,
Os: clientOS,
HasRuntimeProbe: req.Runtime != nil,
ProbeResult: runtime.Result,
RuntimeCount: runtime.RuntimeCount,
ProviderSummary: runtime.ProviderSummary,
OnlineCount: runtime.OnlineCount,
OfflineCount: runtime.OfflineCount,
}); err != nil {
slog.Error("failed to upsert client usage", "error", err, "client_type", clientType)
writeError(w, http.StatusInternalServerError, "failed to record client usage")
return
}
if tx != nil {
if err := tx.Commit(r.Context()); err != nil {
slog.Error("failed to commit client usage", "error", err, "client_type", clientType)
writeError(w, http.StatusInternalServerError, "failed to record client usage")
return
}
}
w.WriteHeader(http.StatusNoContent)
}
func normalizeClientUsageOS(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
switch value {
case "macos", "windows", "linux", "ios", "android", "chromeos":
return value
default:
return "unknown"
}
}
func validateClientUsageRuntime(probe clientUsageRuntimeProbe) (validatedRuntimeProbe, error) {
result := strings.ToLower(strings.TrimSpace(probe.ProbeResult))
if result != "success" && result != "error" {
return validatedRuntimeProbe{}, errors.New("runtime probe_result must be success or error")
}
validated := validatedRuntimeProbe{Result: pgtype.Text{String: result, Valid: true}}
if result == "error" {
if probe.RuntimeCount != nil || probe.ProviderSummary != nil || probe.OnlineCount != nil || probe.OfflineCount != nil {
return validatedRuntimeProbe{}, errors.New("failed runtime probes must not include counts")
}
return validated, nil
}
if probe.RuntimeCount == nil || probe.ProviderSummary == nil || probe.OnlineCount == nil || probe.OfflineCount == nil {
return validatedRuntimeProbe{}, errors.New("successful runtime probes require all counts")
}
if *probe.RuntimeCount < 0 || *probe.RuntimeCount > 1000 || *probe.OnlineCount < 0 || *probe.OfflineCount < 0 || *probe.OnlineCount+*probe.OfflineCount != *probe.RuntimeCount {
return validatedRuntimeProbe{}, errors.New("invalid runtime counts")
}
if len(probe.ProviderSummary) > 32 {
return validatedRuntimeProbe{}, errors.New("too many runtime providers")
}
var providerTotal int64
for provider, count := range probe.ProviderSummary {
if !providerNamePattern.MatchString(provider) || count < 0 || count > 1000 {
return validatedRuntimeProbe{}, errors.New("invalid runtime provider summary")
}
providerTotal += int64(count)
}
if providerTotal != int64(*probe.RuntimeCount) {
return validatedRuntimeProbe{}, errors.New("runtime provider counts do not match runtime_count")
}
summary, err := json.Marshal(probe.ProviderSummary)
if err != nil {
return validatedRuntimeProbe{}, errors.New("invalid runtime provider summary")
}
validated.RuntimeCount = pgtype.Int4{Int32: *probe.RuntimeCount, Valid: true}
validated.ProviderSummary = summary
validated.OnlineCount = pgtype.Int4{Int32: *probe.OnlineCount, Valid: true}
validated.OfflineCount = pgtype.Int4{Int32: *probe.OfflineCount, Valid: true}
return validated, nil
}