Files
rcourtman 5288b64d40 fix(patrol): preserve refusals and expose measured summary evidence
A provider policy refusal was classified as a connection failure, while
summary tools mixed high-utilisation heuristics with unchecked health claims.
Preserve explicit refusals before tool recovery and provide retained metrics
with source scope, observation timestamps and bucket extrema for diagnosis.

Record the live qualification limits and the shared temporal tier-query gap.
2026-09-05 22:29:48 +01:00

90 lines
3.1 KiB
Go

package reporting
import (
"context"
"fmt"
"time"
)
// MetricEvidenceProvider exposes the same retained metrics as reports without
// invoking a narrator or assigning health, urgency, or recommended actions.
type MetricEvidenceProvider interface {
MetricEvidenceFor(context.Context, MetricReportRequest) (*MetricEvidence, error)
}
type MetricEvidence struct {
ResourceID string `json:"resource_id"`
ResourceType string `json:"resource_type"`
GeneratedAt time.Time `json:"generated_at"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
Metrics map[string]RetainedMetricStats `json:"metrics"`
}
// RetainedMetricStats describes returned points, which may already be retention
// aggregates. Their count and time span do not prove continuous observation.
type RetainedMetricStats struct {
Unit string `json:"unit,omitempty"`
RetainedPoints int `json:"retained_points"`
Min float64 `json:"min"`
Max float64 `json:"max"`
Mean float64 `json:"mean"`
Latest float64 `json:"latest"`
MinBucketAt time.Time `json:"min_bucket_at"`
MaxBucketAt time.Time `json:"max_bucket_at"`
FirstAt time.Time `json:"first_at"`
LastAt time.Time `json:"last_at"`
MaxGapSeconds float64 `json:"max_gap_seconds"`
}
func (e *ReportEngine) MetricEvidenceFor(ctx context.Context, req MetricReportRequest) (*MetricEvidence, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if e.getMetricsStore() == nil {
return nil, fmt.Errorf("metrics store not initialized")
}
data, err := e.queryMetrics(req)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
evidence := &MetricEvidence{
ResourceID: data.ResourceID, ResourceType: data.ResourceType,
GeneratedAt: data.GeneratedAt, WindowStart: data.Start, WindowEnd: data.End,
Metrics: make(map[string]RetainedMetricStats, len(data.Metrics)),
}
for name, points := range data.Metrics {
if len(points) == 0 {
continue
}
stats := data.Summary.ByMetric[name]
entry := RetainedMetricStats{
Unit: GetMetricUnit(name), RetainedPoints: len(points),
Min: points[0].Value, Max: points[0].Value, Mean: stats.Avg, Latest: stats.Current,
MinBucketAt: points[0].Timestamp, MaxBucketAt: points[0].Timestamp,
FirstAt: points[0].Timestamp, LastAt: points[len(points)-1].Timestamp,
}
for i, point := range points {
// Retention buckets preserve extrema separately from their mean.
low, high := point.Value, point.Value
if point.Min <= point.Value && point.Max >= point.Value {
low, high = point.Min, point.Max
}
if low < entry.Min {
entry.Min, entry.MinBucketAt = low, point.Timestamp
}
if high > entry.Max {
entry.Max, entry.MaxBucketAt = high, point.Timestamp
}
if i > 0 {
entry.MaxGapSeconds = max(entry.MaxGapSeconds, point.Timestamp.Sub(points[i-1].Timestamp).Seconds())
}
}
evidence.Metrics[name] = entry
}
return evidence, nil
}