diff --git a/analyticsevent/events.go b/analyticsevent/events.go index 0d4d300..f5214da 100644 --- a/analyticsevent/events.go +++ b/analyticsevent/events.go @@ -21,6 +21,14 @@ const ( // every other dashboard can exclude this traffic from real KPIs. EventFlowTest = "InstantFlowTest" + // EventPaymentProbe is one Layer-3 payment-prober leg result per tick, + // pushed by the worker's payment_probe synthetic. Carries cohort="synthetic" + // so business/billing dashboards exclude it from real revenue KPIs. It is + // the money-path heartbeat companion to EventFlowTest (one event per probe + // leg: checkout_reachable / billing_state / invoices_reachable / + // webhook_security / upgrade_webhook_e2e). + EventPaymentProbe = "InstantPaymentProbe" + // EventChurnSignal flags a behavioral churn indicator on a paid team (e.g. // no activity in N days). Feeds the churn-trend tile (WS4-P5). EventChurnSignal = "InstantChurnSignal" @@ -89,6 +97,7 @@ const ( AttrReason = "reason" // short failure reason (free text, no PII) AttrLatencyMs = "latencyMs" // observed latency in milliseconds AttrSyntheticRunID = "syntheticRunId" // groups all flows from one prober tick + AttrHTTPStatus = "httpStatus" // HTTP status code observed on the probed endpoint (0 = no response) // Generic event metadata (non-PII; allowlisted). AttrService = "service" // free-form sub-service / handler name @@ -158,6 +167,60 @@ func RecordFlowTest(ctx context.Context, e Emitter, f FlowTest) { e.Record(ctx, EventFlowTest, f.Attrs()) } +// PaymentProbe is the typed payload for an [EventPaymentProbe] custom event, +// matching the Layer-3 payment-prober contract (docs/ci/FORUM-PAYMENT-E2E-TOOLING.md +// §4 Layer 3). Use [Emitter] with [PaymentProbe.Attrs] (or the +// [RecordPaymentProbe] helper) instead of hand-building the map at each call site. +// One event per probe leg per tick. +type PaymentProbe struct { + // Leg is the payment-funnel leg under test ("checkout_reachable", + // "billing_state", "invoices_reachable", "webhook_security", + // "upgrade_webhook_e2e"). Emitted under [AttrFlow] so the matrix grid keys on + // the same attribute as flow tests. + Leg string + // Result is the outcome (pass / fail / "degraded"). + Result string + // LatencyMs is the observed leg latency in milliseconds. + LatencyMs int64 + // Reason is a short, PII-free outcome reason (empty on a clean pass). + Reason string + // HTTPStatus is the status code observed on the probed endpoint (0 = no + // response, e.g. a config-skipped leg or a transport error). + HTTPStatus int + // SyntheticRunID groups every leg from one prober tick (UUID). + SyntheticRunID string +} + +// Attrs renders a [PaymentProbe] into the flat attribute map an [Emitter] +// consumes. Cohort is always [CohortSynthetic] so billing/revenue dashboards +// exclude probe traffic. The leg is emitted under [AttrFlow] (the same matrix +// axis flow tests use). Empty/zero fields are omitted so an absent value reads +// as "missing" in NRQL. Already allowlist-clean (no PII keys). +func (p PaymentProbe) Attrs() map[string]any { + out := make(map[string]any, 6) + putStr(out, AttrFlow, p.Leg) + putStr(out, AttrResult, p.Result) + putStr(out, AttrReason, p.Reason) + putStr(out, AttrSyntheticRunID, p.SyntheticRunID) + out[AttrLatencyMs] = p.LatencyMs + if p.HTTPStatus != 0 { + out[AttrHTTPStatus] = p.HTTPStatus + } + out[AttrCohort] = CohortSynthetic + return out +} + +// RecordPaymentProbe is the ergonomic typed helper for the Layer-3 payment +// prober: it builds the [EventPaymentProbe] attribute map from a [PaymentProbe] +// and records it. Fire-and-forget, same fail-open contract as [Emitter.Record] +// (a nil emitter is a no-op). +func RecordPaymentProbe(ctx context.Context, e Emitter, p PaymentProbe) { + if e == nil { + return + } + e.Record(ctx, EventPaymentProbe, p.Attrs()) +} + // putStr sets out[key]=val only when val is non-empty, so callers can build a // map without "" placeholders polluting NRQL facets. func putStr(out map[string]any, key, val string) { diff --git a/analyticsevent/events_test.go b/analyticsevent/events_test.go index 03db096..e3ac6a1 100644 --- a/analyticsevent/events_test.go +++ b/analyticsevent/events_test.go @@ -70,6 +70,77 @@ func TestRecordFlowTest_NilEmitterSafe(t *testing.T) { RecordFlowTest(context.Background(), nil, FlowTest{Flow: "x"}) } +func TestPaymentProbe_Attrs(t *testing.T) { + p := PaymentProbe{ + Leg: "checkout_reachable", + Result: ResultPass, + LatencyMs: 87, + HTTPStatus: 200, + SyntheticRunID: "run-uuid", + } + a := p.Attrs() + // The leg is emitted under AttrFlow (the same matrix axis flow tests use). + if a[AttrFlow] != "checkout_reachable" || a[AttrResult] != ResultPass || + a[AttrLatencyMs] != int64(87) || a[AttrHTTPStatus] != 200 || + a[AttrSyntheticRunID] != "run-uuid" { + t.Fatalf("Attrs mismatch: %v", a) + } + // Cohort is always synthetic so billing/revenue dashboards exclude it. + if a[AttrCohort] != CohortSynthetic { + t.Fatalf("cohort = %v, want %q", a[AttrCohort], CohortSynthetic) + } + // Empty Reason on pass is omitted (no "" placeholder). + if _, ok := a[AttrReason]; ok { + t.Errorf("empty Reason should be omitted, got %v", a[AttrReason]) + } +} + +func TestPaymentProbe_Attrs_FailIncludesReason(t *testing.T) { + p := PaymentProbe{Leg: "webhook_security", Result: ResultFail, Reason: "unsigned ACCEPTED"} + a := p.Attrs() + if a[AttrReason] != "unsigned ACCEPTED" { + t.Fatalf("Reason not included on fail: %v", a) + } +} + +func TestPaymentProbe_Attrs_ZeroHTTPStatusOmitted(t *testing.T) { + // A config-skipped leg has no HTTP response (status 0) — the key is omitted. + p := PaymentProbe{Leg: "upgrade_webhook_e2e", Result: "degraded"} + a := p.Attrs() + if _, ok := a[AttrHTTPStatus]; ok { + t.Errorf("zero HTTPStatus should be omitted, got %v", a[AttrHTTPStatus]) + } +} + +func TestPaymentProbe_Attrs_AllAllowlisted(t *testing.T) { + // A populated PaymentProbe must produce only allowlisted (PII-safe) keys. + p := PaymentProbe{Leg: "x", Result: "r", Reason: "why", HTTPStatus: 500, + SyntheticRunID: "s", LatencyMs: 1} + for k := range p.Attrs() { + if _, ok := AllowedAttributes[k]; !ok { + t.Errorf("PaymentProbe emits non-allowlisted key %q", k) + } + } +} + +func TestRecordPaymentProbe(t *testing.T) { + rec := &recorder{} + e := Wrap(rec) + RecordPaymentProbe(context.Background(), e, PaymentProbe{Leg: "billing_state", Result: ResultPass}) + got := rec.last() + if got.eventType != EventPaymentProbe { + t.Fatalf("eventType = %q, want %q", got.eventType, EventPaymentProbe) + } + if got.attrs[AttrFlow] != "billing_state" { + t.Fatalf("leg not recorded under AttrFlow: %v", got.attrs) + } +} + +func TestRecordPaymentProbe_NilEmitterSafe(t *testing.T) { + // Must not panic with a nil emitter. + RecordPaymentProbe(context.Background(), nil, PaymentProbe{Leg: "x"}) +} + func TestNowUnixMilli(t *testing.T) { // Retained single time source — exercise it so it stays live. if nowUnixMilli() <= 0 { diff --git a/analyticsevent/sanitise.go b/analyticsevent/sanitise.go index fe3d2b4..e6cdde8 100644 --- a/analyticsevent/sanitise.go +++ b/analyticsevent/sanitise.go @@ -37,6 +37,7 @@ var AllowedAttributes = map[string]struct{}{ AttrReason: {}, AttrLatencyMs: {}, AttrSyntheticRunID: {}, + AttrHTTPStatus: {}, // generic metadata AttrService: {}, AttrReasonCode: {},