owpengram-server/internal/domain/client_telemetry.go
2026-07-24 11:56:59 +08:00

131 lines
3.8 KiB
Go

package domain
import (
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
"sort"
"time"
)
const (
MaxClientTelemetrySubjects = 100
MaxClientTelemetryPayloadBytes = 64 << 10
MaxClientTelemetryEventsPerHour = 1000
MaxClientTelemetryEventsPerDay = 10000
)
var (
ErrClientTelemetryInvalid = errors.New("client telemetry invalid")
ErrClientTelemetryRateLimited = errors.New("client telemetry rate limited")
)
type ClientTelemetryKind string
const (
ClientTelemetryMessageDelivery ClientTelemetryKind = "message_delivery"
ClientTelemetryReadMetrics ClientTelemetryKind = "read_metrics"
ClientTelemetryMusicListen ClientTelemetryKind = "music_listen"
)
func (k ClientTelemetryKind) Valid() bool {
switch k {
case ClientTelemetryMessageDelivery, ClientTelemetryReadMetrics,
ClientTelemetryMusicListen:
return true
default:
return false
}
}
// ClientTelemetryEvent is operational product telemetry. It is deliberately
// isolated from moderation reports/cases and has TTL-based retention.
type ClientTelemetryEvent struct {
ID int64
UserID int64
Kind ClientTelemetryKind
Peer Peer
SubjectIDs []int64
Payload json.RawMessage
Fingerprint [sha256.Size]byte
CreatedAt time.Time
}
func NewClientTelemetryEvent(userID int64, kind ClientTelemetryKind, peer Peer, subjectIDs []int64, payload any, createdAt time.Time) (ClientTelemetryEvent, error) {
canonicalIDs := append([]int64(nil), subjectIDs...)
sort.Slice(canonicalIDs, func(i, j int) bool { return canonicalIDs[i] < canonicalIDs[j] })
for i, id := range canonicalIDs {
if id <= 0 || (i > 0 && canonicalIDs[i-1] == id) {
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
}
}
raw, err := json.Marshal(payload)
if err != nil {
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
}
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil || object == nil {
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
}
raw, err = json.Marshal(object)
if err != nil || len(raw) > MaxClientTelemetryPayloadBytes {
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
}
event := ClientTelemetryEvent{
UserID: userID, Kind: kind, Peer: peer,
SubjectIDs: canonicalIDs, Payload: raw, CreatedAt: createdAt.UTC(),
}
fingerprintInput, err := json.Marshal(struct {
Version int
UserID int64
Kind ClientTelemetryKind
Peer Peer
SubjectIDs []int64
Payload json.RawMessage
Minute int64
}{
Version: 1, UserID: event.UserID, Kind: event.Kind, Peer: event.Peer,
SubjectIDs: event.SubjectIDs, Payload: event.Payload,
Minute: event.CreatedAt.Truncate(time.Minute).Unix(),
})
if err != nil {
return ClientTelemetryEvent{}, ErrClientTelemetryInvalid
}
event.Fingerprint = sha256.Sum256(fingerprintInput)
if err := event.Validate(); err != nil {
return ClientTelemetryEvent{}, err
}
return event, nil
}
func (e ClientTelemetryEvent) Validate() error {
if e.ID < 0 || e.UserID <= 0 || !e.Kind.Valid() ||
len(e.SubjectIDs) == 0 ||
len(e.SubjectIDs) > MaxClientTelemetrySubjects ||
len(e.Payload) == 0 || len(e.Payload) > MaxClientTelemetryPayloadBytes ||
e.Fingerprint == ([sha256.Size]byte{}) || e.CreatedAt.IsZero() {
return ErrClientTelemetryInvalid
}
if e.Peer.ID == 0 {
if e.Peer.Type != "" || e.Kind != ClientTelemetryMusicListen {
return ErrClientTelemetryInvalid
}
} else if !moderationPeerValid(e.Peer) {
return ErrClientTelemetryInvalid
}
for i, id := range e.SubjectIDs {
if id <= 0 || (i > 0 && e.SubjectIDs[i-1] >= id) {
return ErrClientTelemetryInvalid
}
}
var object map[string]any
if err := json.Unmarshal(e.Payload, &object); err != nil || object == nil {
return ErrClientTelemetryInvalid
}
canonical, err := json.Marshal(object)
if err != nil || !bytes.Equal(canonical, e.Payload) {
return ErrClientTelemetryInvalid
}
return nil
}