192 lines
5.9 KiB
Go
192 lines
5.9 KiB
Go
|
|
package observation
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/rand"
|
||
|
|
"encoding/hex"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
||
|
|
"github.com/tegwick/fluid-core/internal/evidence"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Ingest normalizes, redacts and stores telemetry.
|
||
|
|
//
|
||
|
|
// Redaction happens here rather than at the query side because the evidence
|
||
|
|
// store is append-only: anything written unredacted stays unredacted forever.
|
||
|
|
// The filter belongs on the way in, where there is still a decision to make.
|
||
|
|
type Ingest struct {
|
||
|
|
store evidence.Store
|
||
|
|
policy RedactionPolicy
|
||
|
|
iface contract.InterfaceID
|
||
|
|
now func() time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewIngest returns an ingest pipeline.
|
||
|
|
func NewIngest(store evidence.Store, iface contract.InterfaceID, policy RedactionPolicy) (*Ingest, error) {
|
||
|
|
if err := policy.Validate(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return &Ingest{store: store, policy: policy, iface: iface, now: time.Now}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ErrWrongInterface reports telemetry submitted for another interface.
|
||
|
|
var ErrWrongInterface = errors.New("telemetry belongs to a different interface")
|
||
|
|
|
||
|
|
// Normalize fills in defaults and applies redaction.
|
||
|
|
//
|
||
|
|
// It is separated from Write so the same normalization runs whether an event
|
||
|
|
// arrives from the in-process emitter or over the ingest endpoint. Two paths
|
||
|
|
// with two normalizations would eventually disagree, and the disagreement would
|
||
|
|
// surface as a pressure finding that is really a bug in the pipeline.
|
||
|
|
func (i *Ingest) Normalize(ev contract.FluidTelemetry) (contract.FluidTelemetry, error) {
|
||
|
|
if ev.InterfaceID == "" {
|
||
|
|
ev.InterfaceID = i.iface
|
||
|
|
}
|
||
|
|
if ev.InterfaceID != i.iface {
|
||
|
|
return ev, fmt.Errorf("%w: event is for %q, ingest serves %q",
|
||
|
|
ErrWrongInterface, ev.InterfaceID, i.iface)
|
||
|
|
}
|
||
|
|
|
||
|
|
if ev.SchemaVersion == "" {
|
||
|
|
ev.SchemaVersion = "0.1"
|
||
|
|
}
|
||
|
|
if ev.ID == "" {
|
||
|
|
ev.ID = newID("tl-")
|
||
|
|
}
|
||
|
|
if ev.OccurredAt.IsZero() {
|
||
|
|
ev.OccurredAt = i.now().UTC()
|
||
|
|
}
|
||
|
|
ev.OccurredAt = ev.OccurredAt.UTC()
|
||
|
|
|
||
|
|
if ev.Kind == "" {
|
||
|
|
// Infer from shape rather than defaulting to "request": an error event
|
||
|
|
// filed as a request would understate the interface's failure rate.
|
||
|
|
switch {
|
||
|
|
case ev.Error != nil:
|
||
|
|
ev.Kind = contract.FluidTelemetryKindError
|
||
|
|
case ev.Adoption != nil:
|
||
|
|
ev.Kind = contract.FluidTelemetryKindAdoption
|
||
|
|
case ev.Sequence != nil:
|
||
|
|
ev.Kind = contract.FluidTelemetryKindSequence
|
||
|
|
default:
|
||
|
|
ev.Kind = contract.FluidTelemetryKindRequest
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !ev.Kind.Valid() {
|
||
|
|
return ev, fmt.Errorf("unknown telemetry kind %q", ev.Kind)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Raw payload capture is not the default (Blueprint 6.2). Where a policy
|
||
|
|
// forbids it, error detail is the only free-text field that survives, and
|
||
|
|
// it is scrubbed below.
|
||
|
|
if !i.policy.AllowRawPayload && ev.Error != nil && ev.Error.Detail != "" {
|
||
|
|
if len(ev.Error.Detail) > 512 {
|
||
|
|
ev.Error.Detail = ev.Error.Detail[:512] + "…"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
i.policy.Apply(&ev)
|
||
|
|
return ev, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Write normalizes and stores one event.
|
||
|
|
func (i *Ingest) Write(ctx context.Context, ev contract.FluidTelemetry) error {
|
||
|
|
normalized, err := i.Normalize(ev)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return i.store.WriteTelemetry(ctx, normalized)
|
||
|
|
}
|
||
|
|
|
||
|
|
// WriteBatch stores several events, reporting how many landed.
|
||
|
|
//
|
||
|
|
// A malformed event in a batch does not discard the rest. Telemetry is
|
||
|
|
// best-effort evidence, and dropping a hundred good events because one was
|
||
|
|
// wrong would lose more than it protects.
|
||
|
|
func (i *Ingest) WriteBatch(ctx context.Context, events []contract.FluidTelemetry) (accepted int, rejected []error) {
|
||
|
|
for _, ev := range events {
|
||
|
|
if err := i.Write(ctx, ev); err != nil {
|
||
|
|
rejected = append(rejected, err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
accepted++
|
||
|
|
}
|
||
|
|
return accepted, rejected
|
||
|
|
}
|
||
|
|
|
||
|
|
// RecordFeedback stores explicit consumer feedback.
|
||
|
|
//
|
||
|
|
// FluidAPIStandards.md section 15: feedback is evidence and must not itself
|
||
|
|
// authorize interface changes. It is stored as a record and an event, and
|
||
|
|
// nothing here creates pressure or a hypothesis from it — that stays a
|
||
|
|
// deliberate step someone takes.
|
||
|
|
func (i *Ingest) RecordFeedback(ctx context.Context, f contract.FluidFeedback) (contract.FluidFeedback, error) {
|
||
|
|
if f.Goal == "" {
|
||
|
|
// Feedback with no stated goal cannot be interpreted later; the goal is
|
||
|
|
// the part that says what the consumer was actually trying to do.
|
||
|
|
return f, errors.New("feedback must state a goal")
|
||
|
|
}
|
||
|
|
|
||
|
|
if f.SchemaVersion == "" {
|
||
|
|
f.SchemaVersion = "0.1"
|
||
|
|
}
|
||
|
|
if f.ID == "" {
|
||
|
|
f.ID = contract.FeedbackID(newID("F-"))
|
||
|
|
}
|
||
|
|
if err := contract.RequireKind(string(f.ID), contract.KindFeedback); err != nil {
|
||
|
|
return f, err
|
||
|
|
}
|
||
|
|
if f.InterfaceID == "" {
|
||
|
|
f.InterfaceID = i.iface
|
||
|
|
}
|
||
|
|
if f.InterfaceID != i.iface {
|
||
|
|
return f, fmt.Errorf("%w: feedback is for %q", ErrWrongInterface, f.InterfaceID)
|
||
|
|
}
|
||
|
|
if f.ReceivedAt.IsZero() {
|
||
|
|
f.ReceivedAt = i.now().UTC()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Consumers write free text; it passes the same filter as everything else.
|
||
|
|
f.Goal, _ = i.policy.Scrub(f.Goal)
|
||
|
|
f.Outcome, _ = i.policy.Scrub(f.Outcome)
|
||
|
|
f.Attempt, _ = i.policy.Scrub(f.Attempt)
|
||
|
|
f.MissingCapability, _ = i.policy.Scrub(f.MissingCapability)
|
||
|
|
|
||
|
|
body, err := marshalFeedback(f)
|
||
|
|
if err != nil {
|
||
|
|
return f, err
|
||
|
|
}
|
||
|
|
if err := i.store.PutRecord(ctx, contract.KindFeedback, string(f.ID), body); err != nil {
|
||
|
|
return f, err
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := i.store.AppendEvent(ctx, contract.FluidEvent{
|
||
|
|
SchemaVersion: "0.1",
|
||
|
|
ID: contract.EventID(fmt.Sprintf("EV-%s-%d", f.ID, i.now().UnixNano())),
|
||
|
|
OccurredAt: f.ReceivedAt,
|
||
|
|
EntityType: contract.FluidEventEntityTypeFeedback,
|
||
|
|
EntityID: string(f.ID),
|
||
|
|
EventType: "FEEDBACK_RECEIVED",
|
||
|
|
// The consumer is the actor, and a consumer is untrusted (Blueprint 47).
|
||
|
|
// Recording them as the actor keeps that visible in the audit trail.
|
||
|
|
Actor: contract.Actor{Type: contract.ActorTypeConsumer, ID: consumerLabel(f)},
|
||
|
|
Reason: f.Goal,
|
||
|
|
}); err != nil {
|
||
|
|
return f, err
|
||
|
|
}
|
||
|
|
|
||
|
|
return f, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// newID generates an identifier with the given prefix.
|
||
|
|
func newID(prefix string) string {
|
||
|
|
var b [10]byte
|
||
|
|
if _, err := rand.Read(b[:]); err != nil {
|
||
|
|
return prefix + "0"
|
||
|
|
}
|
||
|
|
return prefix + hex.EncodeToString(b[:])
|
||
|
|
}
|