Compare commits

..

2 commits

Author SHA1 Message Date
7567271536 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.
2026-04-22 16:50:59 +07:00
087032f6cc checker: add DiscoveryPublisher interface for cross-checker discovery
Introduce a DiscoveryEntry struct and an optional DiscoveryPublisher
interface that providers can co-implement to declare things worth
probing by other checkers (TLS endpoints, HTTP probes, ACME challenges,
DNSSEC keys, ...) without having to re-parse raw observations.

DiscoveryEntry carries an opaque Payload: the SDK does not interpret
it. Producers and consumers agree on the Payload schema through a
separate contract (eg. a small shared Go package imported by
both) identified by the free-form Type string. This keeps the SDK
free of protocol-specific concepts; new entry families can appear
without touching it.

The /collect HTTP handler type-asserts the provider against
DiscoveryPublisher immediately after Collect and forwards the
resulting entries in ExternalCollectResponse.Entries.
2026-04-22 16:50:59 +07:00
3 changed files with 8 additions and 9 deletions

View file

@ -259,7 +259,7 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
resp := ExternalCollectResponse{Data: json.RawMessage(raw)}
// Harvest discovery entries from the native Go value, before it goes
// out of scope. No re-parse DiscoverEntries operates on the same
// out of scope. No re-parse; DiscoverEntries operates on the same
// object that was just marshaled above.
if dp, ok := s.provider.(DiscoveryPublisher); ok {
entries, derr := dp.DiscoverEntries(data)

View file

@ -429,7 +429,7 @@ func TestServer_Report_Metrics(t *testing.T) {
}
// TestServer_Report_Related verifies the remote /report path wires
// ExternalReportRequest.Related through to the provider's ReportContext
// 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

View file

@ -245,7 +245,7 @@ type CheckRuleWithOptions interface {
// 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
// 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.
@ -275,7 +275,7 @@ type RelatedObservation struct {
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.
// Opaque to the SDK; meaningful within the producer/consumer contract.
Ref string `json:"ref"`
}
@ -290,7 +290,7 @@ type CheckAggregator interface {
//
// 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.
// Related when there is nothing to relate; reporters must tolerate that.
type ReportContext interface {
Data() json.RawMessage
Related(key ObservationKey) []RelatedObservation
@ -417,13 +417,12 @@ type ExternalCollectResponse struct {
// The SDK treats Payload as an opaque byte string: producer and consumer
// checkers agree on a schema through a separate contract (typically a small
// shared Go package imported by both). This keeps the SDK free of
// protocol-specific concepts new entry families (TLS endpoint, HTTP probe,
// protocol-specific concepts; new entry families (TLS endpoint, HTTP probe,
// ACME challenge, DNSSEC key, …) can appear without touching it.
//
// Entries are ingested by happyDomain into a separate index. Each new
// collection from the same source atomically replaces the set of entries
// previously published for the same (producer, target) pair; see
// DISCOVERY.md for the host-side semantics.
// previously published for the same (producer, target) pair.
type DiscoveryEntry struct {
// Type names the contract Payload follows, e.g. "tls.endpoint" or
// "http.probe". Producers and consumers match on this string; the SDK
@ -471,7 +470,7 @@ 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
// 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