checker: cross-checker observation composition via ReportContext
Add the plumbing that lets a checker receive (at evaluation, report
rendering, and metrics extraction) observations produced by other
checkers on DiscoveryEntry records it originally published.
Surface changes:
- RelatedObservation struct: one downstream observation, tagged with
the producing CheckerID and the Ref matching the DiscoveryEntry
it covers.
- ObservationGetter gains GetRelated(ctx, key), so rules can opt in
to cross-checker composition. mapObservationGetter (remote
/evaluate path) returns empty; the host owns lineage resolution.
- ReportContext interface: Data() + Related(key). Reporters consume
it instead of a raw json.RawMessage, which collapses the former
legacy/Ctx duplicate and gives one uniform signature:
GetHTMLReport(ctx ReportContext) (string, error)
ExtractMetrics(ctx ReportContext, t time.Time) ([]CheckMetric, error)
- NewReportContext(data, related) and StaticReportContext(data) build
fixed-payload contexts for entry points without an ObservationContext.
- ExternalReportRequest gains a Related map so the host can ship
pre-composed lineage to a remote checker over /report. The SDK's
/report handler threads it through to the reporter via
NewReportContext, closing the wire gap that previously forced
remote reports to a StaticReportContext with no related data.
Tests cover the Related map round-trip end-to-end via a peeking provider.
This commit is contained in:
parent
087032f6cc
commit
7567271536
3 changed files with 172 additions and 12 deletions
|
|
@ -319,7 +319,7 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
html, err := reporter.GetHTMLReport(req.Data)
|
||||
html, err := reporter.GetHTMLReport(NewReportContext(req.Data, req.Related))
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to generate HTML report: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
@ -337,7 +337,7 @@ func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
metrics, err := reporter.ExtractMetrics(req.Data, time.Now())
|
||||
metrics, err := reporter.ExtractMetrics(NewReportContext(req.Data, req.Related), time.Now())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": fmt.Sprintf("failed to extract metrics: %v", err),
|
||||
|
|
@ -361,6 +361,15 @@ func (g *mapObservationGetter) Get(ctx context.Context, key ObservationKey, dest
|
|||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
|
||||
// GetRelated always returns nil in the remote /evaluate path: the host that
|
||||
// invokes /evaluate does not (currently) carry cross-checker related data in
|
||||
// ExternalEvaluateRequest. Consumers that need related observations must run
|
||||
// evaluation locally with a host-side ObservationContext that resolves
|
||||
// lineage.
|
||||
func (g *mapObservationGetter) GetRelated(ctx context.Context, key ObservationKey) ([]RelatedObservation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
|
|
|||
|
|
@ -44,15 +44,15 @@ func (p *testProvider) Collect(ctx context.Context, opts CheckerOptions) (any, e
|
|||
return map[string]string{"result": "ok"}, nil
|
||||
}
|
||||
func (p *testProvider) Definition() *CheckerDefinition { return p.definition }
|
||||
func (p *testProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
|
||||
func (p *testProvider) GetHTMLReport(ctx ReportContext) (string, error) {
|
||||
if p.htmlFn != nil {
|
||||
return p.htmlFn(raw)
|
||||
return p.htmlFn(ctx.Data())
|
||||
}
|
||||
return "<h1>report</h1>", nil
|
||||
}
|
||||
func (p *testProvider) ExtractMetrics(raw json.RawMessage, t time.Time) ([]CheckMetric, error) {
|
||||
func (p *testProvider) ExtractMetrics(ctx ReportContext, t time.Time) ([]CheckMetric, error) {
|
||||
if p.metricsFn != nil {
|
||||
return p.metricsFn(raw, t)
|
||||
return p.metricsFn(ctx.Data(), t)
|
||||
}
|
||||
return []CheckMetric{{Name: "m1", Value: 1.0, Timestamp: t}}, nil
|
||||
}
|
||||
|
|
@ -428,6 +428,63 @@ func TestServer_Report_Metrics(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestServer_Report_Related verifies the remote /report path wires
|
||||
// ExternalReportRequest.Related through to the provider's ReportContext,
|
||||
// the fix for the "remote checkers can't see related observations" gap.
|
||||
func TestServer_Report_Related(t *testing.T) {
|
||||
var gotRelated []RelatedObservation
|
||||
p := &testProvider{
|
||||
key: "test",
|
||||
definition: &CheckerDefinition{ID: "test-checker", Rules: []CheckRule{}},
|
||||
}
|
||||
// Replace htmlFn with one that peeks at a related key. We can't do that
|
||||
// directly through testProvider's htmlFn (which only sees raw), so
|
||||
// bind to GetHTMLReport via an inline wrapper: use a per-test provider
|
||||
// that captures the ReportContext before delegating to the template.
|
||||
srv := NewServer(&relatedPeekingProvider{
|
||||
base: p,
|
||||
target: &gotRelated,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
req := ExternalReportRequest{
|
||||
Key: "test",
|
||||
Data: json.RawMessage(`{}`),
|
||||
Related: map[ObservationKey][]RelatedObservation{
|
||||
"tls_probes": {
|
||||
{CheckerID: "tls", Key: "tls_probes", Data: json.RawMessage(`{"ok":true}`), Ref: "ep-1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
rec := doRequest(srv.Handler(), "POST", "/report", req, map[string]string{"Accept": "text/html"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST /report = %d, want 200", rec.Code)
|
||||
}
|
||||
if len(gotRelated) != 1 {
|
||||
t.Fatalf("provider saw %d related observations, want 1", len(gotRelated))
|
||||
}
|
||||
if gotRelated[0].CheckerID != "tls" || string(gotRelated[0].Data) != `{"ok":true}` {
|
||||
t.Errorf("related mismatch: got %+v", gotRelated[0])
|
||||
}
|
||||
}
|
||||
|
||||
// relatedPeekingProvider forwards to a base testProvider but copies the
|
||||
// Related("tls_probes") slice observed at GetHTMLReport time into target.
|
||||
type relatedPeekingProvider struct {
|
||||
base *testProvider
|
||||
target *[]RelatedObservation
|
||||
}
|
||||
|
||||
func (p *relatedPeekingProvider) Key() ObservationKey { return p.base.Key() }
|
||||
func (p *relatedPeekingProvider) Collect(ctx context.Context, opts CheckerOptions) (any, error) {
|
||||
return p.base.Collect(ctx, opts)
|
||||
}
|
||||
func (p *relatedPeekingProvider) Definition() *CheckerDefinition { return p.base.definition }
|
||||
func (p *relatedPeekingProvider) GetHTMLReport(ctx ReportContext) (string, error) {
|
||||
*p.target = ctx.Related("tls_probes")
|
||||
return "<p>ok</p>", nil
|
||||
}
|
||||
|
||||
func TestServer_Report_BadBody(t *testing.T) {
|
||||
p := &testProvider{
|
||||
key: "test",
|
||||
|
|
|
|||
106
checker/types.go
106
checker/types.go
|
|
@ -238,8 +238,45 @@ type CheckRuleWithOptions interface {
|
|||
|
||||
// ObservationGetter provides access to observation data (used by CheckRule).
|
||||
// Get unmarshals observation data into dest (like json.Unmarshal).
|
||||
//
|
||||
// GetRelated returns observations produced by other checkers on DiscoveryEntry
|
||||
// records originally published by the current target. It is the core of
|
||||
// cross-checker composition: a checker that published some entries via its
|
||||
// DiscoveryPublisher can, during rule evaluation, fetch the latest
|
||||
// observations that cover those entries and fold them into its own states.
|
||||
//
|
||||
// GetRelated returns an empty slice (not an error) when there is nothing
|
||||
// to relate (no entries originally published, no downstream observation
|
||||
// yet, no downstream checker registered for the entry type, …). Callers
|
||||
// handle that as "no related data", typically skipping optional sections.
|
||||
type ObservationGetter interface {
|
||||
Get(ctx context.Context, key ObservationKey, dest any) error
|
||||
GetRelated(ctx context.Context, key ObservationKey) ([]RelatedObservation, error)
|
||||
}
|
||||
|
||||
// RelatedObservation is a single observation, produced by some other checker,
|
||||
// that covers a DiscoveryEntry originally published by the current target.
|
||||
//
|
||||
// Data carries the raw JSON payload; consumers parse it according to the
|
||||
// producer's schema, which they are expected to know via external agreement
|
||||
// (typically a shared contract package imported by both producer and
|
||||
// consumer).
|
||||
type RelatedObservation struct {
|
||||
// CheckerID identifies the producer of this observation.
|
||||
CheckerID string `json:"checkerId"`
|
||||
|
||||
// Key is the observation key the producer filled.
|
||||
Key ObservationKey `json:"key"`
|
||||
|
||||
// Data is the raw JSON payload as persisted by the producer.
|
||||
Data json.RawMessage `json:"data"`
|
||||
|
||||
// CollectedAt is when the producer ran its Collect.
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
|
||||
// Ref matches DiscoveryEntry.Ref of the entry this observation covers.
|
||||
// Opaque to the SDK; meaningful within the producer/consumer contract.
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
|
||||
// CheckAggregator combines multiple CheckStates into a single result.
|
||||
|
|
@ -247,20 +284,69 @@ type CheckAggregator interface {
|
|||
Aggregate(states []CheckState) CheckState
|
||||
}
|
||||
|
||||
// ReportContext carries both the primary observation payload and any
|
||||
// observations produced by other checkers that cover the same discovery
|
||||
// entries. Hosts build a ReportContext and hand it to reporter methods.
|
||||
//
|
||||
// The method set is deliberately tiny: a single primary payload (Data) and
|
||||
// a query for related observations by key (Related). Hosts return nil from
|
||||
// Related when there is nothing to relate; reporters must tolerate that.
|
||||
type ReportContext interface {
|
||||
Data() json.RawMessage
|
||||
Related(key ObservationKey) []RelatedObservation
|
||||
}
|
||||
|
||||
// NewReportContext returns a ReportContext backed by a primary payload and
|
||||
// a pre-resolved map of related observations by key. The SDK's /report HTTP
|
||||
// handler uses this to wrap ExternalReportRequest contents; hosts and tests
|
||||
// can use it whenever they already have the related observations in memory.
|
||||
//
|
||||
// Passing a nil or empty related map is fine; Related(key) will then return
|
||||
// nil, just like StaticReportContext.
|
||||
func NewReportContext(data json.RawMessage, related map[ObservationKey][]RelatedObservation) ReportContext {
|
||||
return fixedReportContext{data: data, related: related}
|
||||
}
|
||||
|
||||
// StaticReportContext is a shorthand for NewReportContext(data, nil): a
|
||||
// ReportContext with a primary payload and no related observations.
|
||||
// Intended for tests and ad-hoc callers that have no lineage to supply.
|
||||
func StaticReportContext(data json.RawMessage) ReportContext {
|
||||
return fixedReportContext{data: data}
|
||||
}
|
||||
|
||||
type fixedReportContext struct {
|
||||
data json.RawMessage
|
||||
related map[ObservationKey][]RelatedObservation
|
||||
}
|
||||
|
||||
func (f fixedReportContext) Data() json.RawMessage { return f.data }
|
||||
func (f fixedReportContext) Related(key ObservationKey) []RelatedObservation {
|
||||
if f.related == nil {
|
||||
return nil
|
||||
}
|
||||
return f.related[key]
|
||||
}
|
||||
|
||||
// CheckerHTMLReporter is an optional interface that observation providers can
|
||||
// implement to render their stored data as a full HTML document (for iframe embedding).
|
||||
// Detect support with a type assertion: _, ok := provider.(CheckerHTMLReporter)
|
||||
//
|
||||
// The ReportContext carries the primary observation payload plus any
|
||||
// downstream observations produced on DiscoveryEntry records this checker
|
||||
// published. Implementations that do not need related observations can
|
||||
// simply consume ctx.Data().
|
||||
type CheckerHTMLReporter interface {
|
||||
// GetHTMLReport generates an HTML document from the JSON-encoded observation data.
|
||||
GetHTMLReport(raw json.RawMessage) (string, error)
|
||||
GetHTMLReport(ctx ReportContext) (string, error)
|
||||
}
|
||||
|
||||
// CheckerMetricsReporter is an optional interface that observation providers can
|
||||
// implement to extract time-series metrics from their stored data.
|
||||
// Detect support with a type assertion: _, ok := provider.(CheckerMetricsReporter)
|
||||
//
|
||||
// As with CheckerHTMLReporter, the ReportContext exposes related
|
||||
// observations for cross-checker composition.
|
||||
type CheckerMetricsReporter interface {
|
||||
// ExtractMetrics returns metrics from JSON-encoded observation data.
|
||||
ExtractMetrics(raw json.RawMessage, collectedAt time.Time) ([]CheckMetric, error)
|
||||
ExtractMetrics(ctx ReportContext, collectedAt time.Time) ([]CheckMetric, error)
|
||||
}
|
||||
|
||||
// CheckerDefinitionProvider is an optional interface that observation providers can
|
||||
|
|
@ -382,9 +468,17 @@ type ExternalEvaluateResponse struct {
|
|||
}
|
||||
|
||||
// ExternalReportRequest is sent to POST /report on a remote checker endpoint.
|
||||
//
|
||||
// Related carries observations produced by other checkers on DiscoveryEntry
|
||||
// records originally published by the target of this report, that is, the
|
||||
// cross-checker lineage that ObservationGetter.GetRelated would expose in
|
||||
// the in-process path. The host composes it before making the HTTP request;
|
||||
// when absent, the remote checker receives a context that reports no
|
||||
// related observations (equivalent to StaticReportContext).
|
||||
type ExternalReportRequest struct {
|
||||
Key ObservationKey `json:"key"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Key ObservationKey `json:"key"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Related map[ObservationKey][]RelatedObservation `json:"related,omitempty"`
|
||||
}
|
||||
|
||||
// HealthResponse is returned by GET /health on a remote checker endpoint.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue