Skip to content

Commit 1667b82

Browse files
feat(analyticsevent): InstantPaymentProbe event + AttrHTTPStatus for the Layer-3 payment prober (#48)
The Layer-3 payment prober (worker/internal/jobs/payment_probe.go — the money heartbeat, forum verdict docs/ci/FORUM-PAYMENT-E2E-TOOLING.md §4) emits one event per probe leg per tick. Add the typed event + helper here so the worker records it via the same fail-open, PII-sanitising Emitter as InstantFlowTest. - EventPaymentProbe = "InstantPaymentProbe". - PaymentProbe type + Attrs() + RecordPaymentProbe() helper. cohort="synthetic" baked in so billing/revenue dashboards exclude probe traffic; the leg is emitted under AttrFlow (the same matrix axis flow tests use). - AttrHTTPStatus = "httpStatus" (new allowlisted, non-PII attribute) for the status code observed on the probed endpoint. Allowlist updated + the existing TestFlowTest_Attrs_AllAllowlisted-style contract continues to hold (AttrHTTPStatus is allowlisted; no PII keys). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 07f93f4 commit 1667b82

3 files changed

Lines changed: 135 additions & 0 deletions

File tree

analyticsevent/events.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ const (
2121
// every other dashboard can exclude this traffic from real KPIs.
2222
EventFlowTest = "InstantFlowTest"
2323

24+
// EventPaymentProbe is one Layer-3 payment-prober leg result per tick,
25+
// pushed by the worker's payment_probe synthetic. Carries cohort="synthetic"
26+
// so business/billing dashboards exclude it from real revenue KPIs. It is
27+
// the money-path heartbeat companion to EventFlowTest (one event per probe
28+
// leg: checkout_reachable / billing_state / invoices_reachable /
29+
// webhook_security / upgrade_webhook_e2e).
30+
EventPaymentProbe = "InstantPaymentProbe"
31+
2432
// EventChurnSignal flags a behavioral churn indicator on a paid team (e.g.
2533
// no activity in N days). Feeds the churn-trend tile (WS4-P5).
2634
EventChurnSignal = "InstantChurnSignal"
@@ -89,6 +97,7 @@ const (
8997
AttrReason = "reason" // short failure reason (free text, no PII)
9098
AttrLatencyMs = "latencyMs" // observed latency in milliseconds
9199
AttrSyntheticRunID = "syntheticRunId" // groups all flows from one prober tick
100+
AttrHTTPStatus = "httpStatus" // HTTP status code observed on the probed endpoint (0 = no response)
92101

93102
// Generic event metadata (non-PII; allowlisted).
94103
AttrService = "service" // free-form sub-service / handler name
@@ -158,6 +167,60 @@ func RecordFlowTest(ctx context.Context, e Emitter, f FlowTest) {
158167
e.Record(ctx, EventFlowTest, f.Attrs())
159168
}
160169

170+
// PaymentProbe is the typed payload for an [EventPaymentProbe] custom event,
171+
// matching the Layer-3 payment-prober contract (docs/ci/FORUM-PAYMENT-E2E-TOOLING.md
172+
// §4 Layer 3). Use [Emitter] with [PaymentProbe.Attrs] (or the
173+
// [RecordPaymentProbe] helper) instead of hand-building the map at each call site.
174+
// One event per probe leg per tick.
175+
type PaymentProbe struct {
176+
// Leg is the payment-funnel leg under test ("checkout_reachable",
177+
// "billing_state", "invoices_reachable", "webhook_security",
178+
// "upgrade_webhook_e2e"). Emitted under [AttrFlow] so the matrix grid keys on
179+
// the same attribute as flow tests.
180+
Leg string
181+
// Result is the outcome (pass / fail / "degraded").
182+
Result string
183+
// LatencyMs is the observed leg latency in milliseconds.
184+
LatencyMs int64
185+
// Reason is a short, PII-free outcome reason (empty on a clean pass).
186+
Reason string
187+
// HTTPStatus is the status code observed on the probed endpoint (0 = no
188+
// response, e.g. a config-skipped leg or a transport error).
189+
HTTPStatus int
190+
// SyntheticRunID groups every leg from one prober tick (UUID).
191+
SyntheticRunID string
192+
}
193+
194+
// Attrs renders a [PaymentProbe] into the flat attribute map an [Emitter]
195+
// consumes. Cohort is always [CohortSynthetic] so billing/revenue dashboards
196+
// exclude probe traffic. The leg is emitted under [AttrFlow] (the same matrix
197+
// axis flow tests use). Empty/zero fields are omitted so an absent value reads
198+
// as "missing" in NRQL. Already allowlist-clean (no PII keys).
199+
func (p PaymentProbe) Attrs() map[string]any {
200+
out := make(map[string]any, 6)
201+
putStr(out, AttrFlow, p.Leg)
202+
putStr(out, AttrResult, p.Result)
203+
putStr(out, AttrReason, p.Reason)
204+
putStr(out, AttrSyntheticRunID, p.SyntheticRunID)
205+
out[AttrLatencyMs] = p.LatencyMs
206+
if p.HTTPStatus != 0 {
207+
out[AttrHTTPStatus] = p.HTTPStatus
208+
}
209+
out[AttrCohort] = CohortSynthetic
210+
return out
211+
}
212+
213+
// RecordPaymentProbe is the ergonomic typed helper for the Layer-3 payment
214+
// prober: it builds the [EventPaymentProbe] attribute map from a [PaymentProbe]
215+
// and records it. Fire-and-forget, same fail-open contract as [Emitter.Record]
216+
// (a nil emitter is a no-op).
217+
func RecordPaymentProbe(ctx context.Context, e Emitter, p PaymentProbe) {
218+
if e == nil {
219+
return
220+
}
221+
e.Record(ctx, EventPaymentProbe, p.Attrs())
222+
}
223+
161224
// putStr sets out[key]=val only when val is non-empty, so callers can build a
162225
// map without "" placeholders polluting NRQL facets.
163226
func putStr(out map[string]any, key, val string) {

analyticsevent/events_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,77 @@ func TestRecordFlowTest_NilEmitterSafe(t *testing.T) {
7070
RecordFlowTest(context.Background(), nil, FlowTest{Flow: "x"})
7171
}
7272

73+
func TestPaymentProbe_Attrs(t *testing.T) {
74+
p := PaymentProbe{
75+
Leg: "checkout_reachable",
76+
Result: ResultPass,
77+
LatencyMs: 87,
78+
HTTPStatus: 200,
79+
SyntheticRunID: "run-uuid",
80+
}
81+
a := p.Attrs()
82+
// The leg is emitted under AttrFlow (the same matrix axis flow tests use).
83+
if a[AttrFlow] != "checkout_reachable" || a[AttrResult] != ResultPass ||
84+
a[AttrLatencyMs] != int64(87) || a[AttrHTTPStatus] != 200 ||
85+
a[AttrSyntheticRunID] != "run-uuid" {
86+
t.Fatalf("Attrs mismatch: %v", a)
87+
}
88+
// Cohort is always synthetic so billing/revenue dashboards exclude it.
89+
if a[AttrCohort] != CohortSynthetic {
90+
t.Fatalf("cohort = %v, want %q", a[AttrCohort], CohortSynthetic)
91+
}
92+
// Empty Reason on pass is omitted (no "" placeholder).
93+
if _, ok := a[AttrReason]; ok {
94+
t.Errorf("empty Reason should be omitted, got %v", a[AttrReason])
95+
}
96+
}
97+
98+
func TestPaymentProbe_Attrs_FailIncludesReason(t *testing.T) {
99+
p := PaymentProbe{Leg: "webhook_security", Result: ResultFail, Reason: "unsigned ACCEPTED"}
100+
a := p.Attrs()
101+
if a[AttrReason] != "unsigned ACCEPTED" {
102+
t.Fatalf("Reason not included on fail: %v", a)
103+
}
104+
}
105+
106+
func TestPaymentProbe_Attrs_ZeroHTTPStatusOmitted(t *testing.T) {
107+
// A config-skipped leg has no HTTP response (status 0) — the key is omitted.
108+
p := PaymentProbe{Leg: "upgrade_webhook_e2e", Result: "degraded"}
109+
a := p.Attrs()
110+
if _, ok := a[AttrHTTPStatus]; ok {
111+
t.Errorf("zero HTTPStatus should be omitted, got %v", a[AttrHTTPStatus])
112+
}
113+
}
114+
115+
func TestPaymentProbe_Attrs_AllAllowlisted(t *testing.T) {
116+
// A populated PaymentProbe must produce only allowlisted (PII-safe) keys.
117+
p := PaymentProbe{Leg: "x", Result: "r", Reason: "why", HTTPStatus: 500,
118+
SyntheticRunID: "s", LatencyMs: 1}
119+
for k := range p.Attrs() {
120+
if _, ok := AllowedAttributes[k]; !ok {
121+
t.Errorf("PaymentProbe emits non-allowlisted key %q", k)
122+
}
123+
}
124+
}
125+
126+
func TestRecordPaymentProbe(t *testing.T) {
127+
rec := &recorder{}
128+
e := Wrap(rec)
129+
RecordPaymentProbe(context.Background(), e, PaymentProbe{Leg: "billing_state", Result: ResultPass})
130+
got := rec.last()
131+
if got.eventType != EventPaymentProbe {
132+
t.Fatalf("eventType = %q, want %q", got.eventType, EventPaymentProbe)
133+
}
134+
if got.attrs[AttrFlow] != "billing_state" {
135+
t.Fatalf("leg not recorded under AttrFlow: %v", got.attrs)
136+
}
137+
}
138+
139+
func TestRecordPaymentProbe_NilEmitterSafe(t *testing.T) {
140+
// Must not panic with a nil emitter.
141+
RecordPaymentProbe(context.Background(), nil, PaymentProbe{Leg: "x"})
142+
}
143+
73144
func TestNowUnixMilli(t *testing.T) {
74145
// Retained single time source — exercise it so it stays live.
75146
if nowUnixMilli() <= 0 {

analyticsevent/sanitise.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ var AllowedAttributes = map[string]struct{}{
3737
AttrReason: {},
3838
AttrLatencyMs: {},
3939
AttrSyntheticRunID: {},
40+
AttrHTTPStatus: {},
4041
// generic metadata
4142
AttrService: {},
4243
AttrReasonCode: {},

0 commit comments

Comments
 (0)