iop/apps/edge/internal/service/single_request_metrics.go
toki dc9a9a8c59 feat(agent): 단일 요청 Agent 실행 경계를 구현한다
승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
2026-08-07 07:03:55 +09:00

184 lines
5.6 KiB
Go

package service
import (
"sync"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/zap"
)
const (
singleRequestLifecycleMetric = "iop_edge_single_request_lifecycle_total"
singleRequestDurationMetric = "iop_edge_single_request_duration_seconds"
singleRequestObservationLogKey = "edge_single_request_observation"
)
// singleRequestMetrics contains only fixed-label collectors. Correlation is
// intentionally a log-only field and is never admitted as a metric label.
type singleRequestMetrics struct {
lifecycle *prometheus.CounterVec
duration *prometheus.HistogramVec
}
type singleRequestObservability struct {
metrics *singleRequestMetrics
mu sync.RWMutex
logger *zap.Logger
}
var defaultSingleRequestMetrics struct {
once sync.Once
metrics *singleRequestMetrics
}
func defaultSingleRequestCollectorSet() *singleRequestMetrics {
defaultSingleRequestMetrics.once.Do(func() {
defaultSingleRequestMetrics.metrics = newSingleRequestMetrics(prometheus.DefaultRegisterer)
})
return defaultSingleRequestMetrics.metrics
}
func newSingleRequestObservability(reg prometheus.Registerer, logger *zap.Logger) *singleRequestObservability {
if logger == nil {
logger = zap.NewNop()
}
return &singleRequestObservability{metrics: newSingleRequestMetrics(reg), logger: logger}
}
func newDefaultSingleRequestObservability(logger *zap.Logger) *singleRequestObservability {
if logger == nil {
logger = zap.NewNop()
}
return &singleRequestObservability{metrics: defaultSingleRequestCollectorSet(), logger: logger}
}
func newSingleRequestMetrics(reg prometheus.Registerer) *singleRequestMetrics {
labels := []string{"event_class", "stage", "operation", "outcome", "error_class"}
metrics := &singleRequestMetrics{
lifecycle: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: singleRequestLifecycleMetric,
Help: "Closed Edge single-request lifecycle observations.",
}, labels),
duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: singleRequestDurationMetric,
Help: "Closed Edge single-request lifecycle durations.",
Buckets: prometheus.DefBuckets,
}, labels),
}
if reg == nil {
return metrics
}
metrics.lifecycle = registerSingleRequestCounter(reg, metrics.lifecycle)
metrics.duration = registerSingleRequestHistogram(reg, metrics.duration)
return metrics
}
func registerSingleRequestCounter(reg prometheus.Registerer, collector *prometheus.CounterVec) *prometheus.CounterVec {
if err := reg.Register(collector); err != nil {
if existing, ok := err.(prometheus.AlreadyRegisteredError); ok {
if counter, ok := existing.ExistingCollector.(*prometheus.CounterVec); ok {
return counter
}
}
}
return collector
}
func registerSingleRequestHistogram(reg prometheus.Registerer, collector *prometheus.HistogramVec) *prometheus.HistogramVec {
if err := reg.Register(collector); err != nil {
if existing, ok := err.(prometheus.AlreadyRegisteredError); ok {
if histogram, ok := existing.ExistingCollector.(*prometheus.HistogramVec); ok {
return histogram
}
}
}
return collector
}
// SetSingleRequestObservationLogger installs the bounded production observer.
// It is called during Edge bootstrap before input servers are constructed.
func (s *Service) SetSingleRequestObservationLogger(logger *zap.Logger) {
if s == nil {
return
}
s.SetSingleRequestObserver(newDefaultSingleRequestObservability(logger))
}
// SingleRequestObservationConfigured is a narrow bootstrap test seam. It
// exposes only whether a non-noop observer is present, never the observer or
// any request data.
func (s *Service) SingleRequestObservationConfigured() bool {
if s == nil {
return false
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.singleRequestObserver != nil
}
func (o *singleRequestObservability) Emit(dto singleRequestDTO) error {
if o == nil || o.metrics == nil || !singleRequestDTOIsValid(dto) {
return nil
}
labels := singleRequestMetricLabels(dto)
o.metrics.lifecycle.WithLabelValues(labels...).Inc()
if dto.DurationMS >= 0 {
o.metrics.duration.WithLabelValues(labels...).Observe(float64(dto.DurationMS) / 1000)
}
o.mu.RLock()
logger := o.logger
o.mu.RUnlock()
if logger == nil {
return nil
}
logger.Info(singleRequestObservationLogKey,
zap.String("correlation", singleRequestSanitizeString(dto.Correlation)),
zap.String("event_class", string(dto.EventClass)),
zap.String("stage", singleRequestMetricStage(dto.Stage)),
zap.String("operation", singleRequestMetricOperation(dto.Operation)),
zap.String("outcome", singleRequestMetricOutcome(dto.Outcome)),
zap.String("error_class", singleRequestMetricErrorClass(dto.ErrorClass)),
zap.Int64("duration_ms", dto.DurationMS),
zap.Int("tool_count", dto.ToolCount),
zap.Bool("has_result", dto.HasResult),
)
return nil
}
func singleRequestMetricLabels(dto singleRequestDTO) []string {
return []string{
string(dto.EventClass),
singleRequestMetricStage(dto.Stage),
singleRequestMetricOperation(dto.Operation),
singleRequestMetricOutcome(dto.Outcome),
singleRequestMetricErrorClass(dto.ErrorClass),
}
}
func singleRequestMetricStage(value singleRequestStage) string {
if singleRequestStageIsValid(value) {
return string(value)
}
return "none"
}
func singleRequestMetricOperation(value singleRequestOperation) string {
if singleRequestOperationIsValid(value) {
return string(value)
}
return "none"
}
func singleRequestMetricOutcome(value singleRequestOutcome) string {
if singleRequestOutcomeIsValid(value) {
return string(value)
}
return "none"
}
func singleRequestMetricErrorClass(value singleRequestErrorClass) string {
if singleRequestErrorClassIsValid(value) {
return string(value)
}
return "none"
}