iop/apps/edge/internal/service/single_request_metrics.go
toki d7a150c7fe feat(agent): 단일 요청 실행 경로를 완성한다
Claude의 단일 Anthropic 요청 안에서 IOP가 Plan, Work, Review와 workspace 도구 실행을 끝내고 실제 dev smoke로 계약을 검증할 수 있어야 한다.\n\n완료 task evidence와 마일스톤 검토 상태도 같은 변경에 고정한다.
2026-08-08 23:35:13 +09:00

194 lines
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))
}
// SetSingleRequestObservationLoggerForTesting installs an isolated collector
// registry for cross-package integration tests. Production bootstrap must use
// SetSingleRequestObservationLogger and the process-default collector set.
func (s *Service) SetSingleRequestObservationLoggerForTesting(reg prometheus.Registerer, logger *zap.Logger) {
if s == nil {
return
}
s.SetSingleRequestObserver(newSingleRequestObservability(reg, 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"
}