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

109 lines
3.9 KiB
Go

package service
import (
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestSingleRequestMetrics(t *testing.T) {
if first, second := defaultSingleRequestCollectorSet(), defaultSingleRequestCollectorSet(); first != second {
t.Fatal("default collector set was registered more than once")
}
registry := prometheus.NewRegistry()
core, logs := observer.New(zap.InfoLevel)
collector := newSingleRequestObservability(registry, zap.New(core))
clock := newSingleRequestManualClock(time.Unix(0, 0))
accumulator := newSingleRequestTimingAccumulator(clock, collector)
accumulator.onRequest()
accumulator.onStageEnter(singleRequestStagePlan)
clock.Advance(2 * time.Second)
accumulator.onStageExit(singleRequestStagePlan, singleRequestOutcomeSuccess, "")
accumulator.onTerminal(singleRequestOutcomeSuccess, "", true)
accumulator.onTerminal(singleRequestOutcomeError, singleRequestErrorClassProvider, false)
terminalLabels := map[string]string{
"event_class": "terminal", "stage": "none", "operation": "terminal", "outcome": "success", "error_class": "none",
}
if got := metricValue(t, registry, singleRequestLifecycleMetric, terminalLabels); got != 1 {
t.Fatalf("terminal metric = %v, want 1", got)
}
stageLabels := map[string]string{
"event_class": "stage", "stage": "plan", "operation": "plan", "outcome": "success", "error_class": "none",
}
if got := metricValue(t, registry, singleRequestLifecycleMetric, stageLabels); got != 1 {
t.Fatalf("stage metric = %v, want 1", got)
}
families, err := registry.Gather()
if err != nil {
t.Fatalf("gather metrics: %v", err)
}
for _, family := range families {
for _, metric := range family.Metric {
for _, label := range metric.Label {
if label.GetName() == "correlation" || strings.Contains(label.GetValue(), "sr-") {
t.Fatalf("unbounded correlation label: %s=%q", label.GetName(), label.GetValue())
}
}
}
}
allowed := map[string]bool{
"correlation": true, "event_class": true, "stage": true, "operation": true,
"outcome": true, "error_class": true, "duration_ms": true, "tool_count": true, "has_result": true,
}
entries := logs.All()
if len(entries) != 3 {
t.Fatalf("observation logs = %d, want 3", len(entries))
}
for _, entry := range entries {
if entry.Message != singleRequestObservationLogKey {
t.Fatalf("log message = %q", entry.Message)
}
if len(entry.Context) != len(allowed) {
t.Fatalf("log field count = %d, want %d", len(entry.Context), len(allowed))
}
for _, field := range entry.Context {
if !allowed[field.Key] {
t.Fatalf("unexpected log key %q", field.Key)
}
}
}
}
func TestSingleRequestMetricsRejectSecretSentinelAndIsolatesObserverFailures(t *testing.T) {
registry := prometheus.NewRegistry()
core, logs := observer.New(zap.InfoLevel)
collector := newSingleRequestObservability(registry, zap.New(core))
if err := collector.Emit(singleRequestDTO{
EventClass: singleRequestEventClassTerminal, Operation: singleRequestOperationTerminal,
Outcome: singleRequestOutcomeError, ErrorClass: singleRequestErrorClassProvider,
Correlation: "SECRET_PATH_COMMAND_BEARER", DurationMS: 1,
}); err != nil {
t.Fatalf("Emit: %v", err)
}
for _, entry := range logs.All() {
if strings.Contains(strings.ToLower(fmt.Sprint(entry.ContextMap()["correlation"])), "secret") {
t.Fatalf("secret sentinel leaked: %+v", entry)
}
}
failing := singleRequestObserverFunc(func(singleRequestDTO) error { return errors.New("observer failure") })
accumulator := newSingleRequestTimingAccumulator(nil, failing)
accumulator.onTerminal(singleRequestOutcomeSuccess, "", true)
if got := accumulator.observer.failureCount(); got != 1 {
t.Fatalf("isolated failures = %d, want 1", got)
}
}
type singleRequestObserverFunc func(singleRequestDTO) error
func (fn singleRequestObserverFunc) Emit(dto singleRequestDTO) error { return fn(dto) }