From 7567271536de6045f0f23870bcf0b3be80c0e707 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 19 Apr 2026 23:35:42 +0700 Subject: [PATCH] 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. --- checker/server.go | 13 ++++- checker/server_test.go | 65 +++++++++++++++++++++++-- checker/types.go | 106 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 172 insertions(+), 12 deletions(-) diff --git a/checker/server.go b/checker/server.go index db7f88c..8b2eb31 100644 --- a/checker/server.go +++ b/checker/server.go @@ -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) diff --git a/checker/server_test.go b/checker/server_test.go index a20ee61..6a228b6 100644 --- a/checker/server_test.go +++ b/checker/server_test.go @@ -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 "

report

", 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 "

ok

", nil +} + func TestServer_Report_BadBody(t *testing.T) { p := &testProvider{ key: "test", diff --git a/checker/types.go b/checker/types.go index 2c26a63..9099a2f 100644 --- a/checker/types.go +++ b/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.