proxy-pool/internal/domain/outcome/outcome.go
youfak 6766097ea7
Some checks are pending
ci / proto (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
ci / race (push) Waiting to run
ci / integration (push) Waiting to run
feat: report gateway proxy outcomes
2026-07-31 17:36:17 +08:00

111 lines
3.0 KiB
Go

package outcome
import (
"crypto/sha256"
"encoding/json"
"errors"
"regexp"
"time"
)
const (
maximumIdentifierBytes = 128
MaxBatchesPerStream = 16
)
var (
ErrInvalidBatch = errors.New("invalid proxy outcome batch")
identifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
)
// Stage identifies the furthest proxy-processing boundary reached by a
// request. It is deliberately independent of a particular transport error.
type Stage uint8
const (
StageUnspecified Stage = iota
StageDial
StageProxyHandshake
StageResponseHeaders
StageTunnel
)
// ErrorClass is a bounded category suitable for aggregation and metrics.
// It must never contain raw connection, target, or credential details.
type ErrorClass string
const (
ErrorClassDial ErrorClass = "dial"
ErrorClassTimeout ErrorClass = "timeout"
ErrorClassProxyResponse ErrorClass = "proxy_response"
ErrorClassHandshake ErrorClass = "handshake"
ErrorClassRelay ErrorClass = "relay"
ErrorClassCanceled ErrorClass = "canceled"
ErrorClassInternal ErrorClass = "internal"
)
type Event struct {
ProxyID string
RoutingName string
Stage Stage
Success bool
ErrorClass ErrorClass
Latency time.Duration
ObservedAt time.Time
}
type Batch struct {
WorkerID string
SessionID string
Sequence uint64
Events []Event
}
// NormalizeBatch validates one bounded report, deep-copies it, and returns a
// digest used by the Controller's durable idempotency fence.
func NormalizeBatch(batch Batch, maximumEvents int) (Batch, [sha256.Size]byte, error) {
if !validIdentifier(batch.WorkerID) || !validIdentifier(batch.SessionID) || batch.Sequence == 0 ||
maximumEvents <= 0 || len(batch.Events) == 0 || len(batch.Events) > maximumEvents {
return Batch{}, [sha256.Size]byte{}, ErrInvalidBatch
}
normalized := batch
normalized.Events = append([]Event(nil), batch.Events...)
for index := range normalized.Events {
event := &normalized.Events[index]
if !validIdentifier(event.ProxyID) || (event.RoutingName != "" && !validIdentifier(event.RoutingName)) ||
!validStage(event.Stage) || event.Latency < 0 || event.ObservedAt.IsZero() ||
(event.Success && event.ErrorClass != "") || (!event.Success && !validErrorClass(event.ErrorClass)) {
return Batch{}, [sha256.Size]byte{}, ErrInvalidBatch
}
event.ObservedAt = event.ObservedAt.UTC()
}
payload, err := json.Marshal(normalized)
if err != nil {
return Batch{}, [sha256.Size]byte{}, ErrInvalidBatch
}
return normalized, sha256.Sum256(payload), nil
}
func validIdentifier(value string) bool {
return len(value) <= maximumIdentifierBytes && identifier.MatchString(value)
}
func validStage(stage Stage) bool {
switch stage {
case StageDial, StageProxyHandshake, StageResponseHeaders, StageTunnel:
return true
default:
return false
}
}
func validErrorClass(class ErrorClass) bool {
switch class {
case ErrorClassDial, ErrorClassTimeout, ErrorClassProxyResponse, ErrorClassHandshake,
ErrorClassRelay, ErrorClassCanceled, ErrorClassInternal:
return true
default:
return false
}
}