Skip to content

Commit 688e0f0

Browse files
feat(analyticsevent): backend→NR custom-event bridge (WS4-P1, F1) (#44)
Add common/analyticsevent — the foundation for the WS4 behavioral- intelligence layer. Before this, `grep RecordCustomEvent api/ worker/` returned 0 hits, so funnel KPIs (anon→claimed, claimed→paid) and the synthetic InstantFlowTest matrix had no way to reach New Relic. Shaped like storageprovider/queueprovider/featureflag: one Emitter interface, swappable backends via Factory(ANALYTICS_BACKEND), with the NR Go-agent dep quarantined in the analyticsevent/nr subpackage so noop- only consumers don't pull it into their import graph. - Emitter.Record(ctx, eventType, attrs) — fire-and-forget, no error return. - Backends: noop (default, zero-dep, never errors) + newrelic (wraps an existing *newrelic.Application via Config.Override → RecordCustomEvent). - FAIL OPEN (inverse of featureflag's fail-closed): Wrap recovers every panic and swallows sink errors so analytics never blocks/errors a request path; degrade ladder always returns a usable noop emitter. - PII allowlist: Sanitize default-denies every key not in AllowedAttributes; email is hashed (sha256(lower(trim))[:16]) into emailHash, never raw — no tokens/connection strings can escape. Enforced at the Wrap chokepoint. - Well-known event types + attr constants: InstantFunnel, InstantFlowTest, InstantChurnSignal, InstantAbuseSignal; typed FlowTest{flow,actor,tier, layer,result,latencyMs,commitId,...} helper matching the synthetics plan. - nr sink exposes a FailureHook seam so services wire instant_analytics_emit_failed_total without common knowing the metrics lib. NOT wired into api/worker here (one-tree discipline; per-service emit-site PRs follow). Package + tests only. Tests: 100% coverage both packages. go build/vet/test ./... green. golangci-lint 0 issues. govulncheck clean on the dep tree (NR v3.43.3 = the version already in worker; only 3 stdlib findings, all tied to the local go1.26.2 toolchain, fixed in 1.26.3/1.26.4 — not in this code's path). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ea5231f commit 688e0f0

15 files changed

Lines changed: 1330 additions & 0 deletions

analyticsevent/analyticsevent.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package analyticsevent
2+
3+
import "context"
4+
5+
// Wrap decorates a concrete [Emitter] with the package's two non-negotiable
6+
// guarantees:
7+
//
8+
// 1. FAIL OPEN — a panic inside the backend's Record (or Close) is recovered
9+
// and swallowed so an analytics hiccup can NEVER crash or error a caller's
10+
// request path. Record has no error return, so swallowing is the contract.
11+
// 2. PII — every attribute map is run through [Sanitize] (allowlist + email
12+
// hashing) BEFORE the concrete backend sees it, so no backend can emit a
13+
// raw email / token / connection string even if an emit site passed one.
14+
//
15+
// This is the single chokepoint that makes the package contract true. Factory
16+
// always returns a wrapped emitter, so no call site holds a raw backend.
17+
//
18+
// Wrapping is idempotent: Wrap(Wrap(e)) behaves identically to Wrap(e). A nil
19+
// backend wraps to the no-op emitter (the most fail-open emitter possible).
20+
func Wrap(e Emitter) Emitter {
21+
if e == nil {
22+
return wrapped{inner: NewNoop()}
23+
}
24+
if w, already := e.(wrapped); already {
25+
return w
26+
}
27+
return wrapped{inner: e}
28+
}
29+
30+
// wrapped is the fail-open + PII-sanitizing decorator. Unexported; construct via
31+
// [Wrap]. Value receiver (it holds only an interface) so it is cheap to copy and
32+
// the idempotency type-assertion in Wrap is straightforward.
33+
type wrapped struct {
34+
inner Emitter
35+
}
36+
37+
// Record sanitizes attrs (allowlist + email-hash) then forwards to the backend,
38+
// recovering and swallowing any panic so the caller's path is never affected.
39+
func (w wrapped) Record(ctx context.Context, eventType string, attrs map[string]any) {
40+
defer func() { _ = recover() }() // fail open: analytics must never panic upward
41+
if eventType == "" {
42+
return // a typeless event is meaningless and un-queryable; drop it
43+
}
44+
w.inner.Record(ctx, eventType, Sanitize(attrs))
45+
}
46+
47+
// Name reports the wrapped backend's name (the wrapper is transparent).
48+
func (w wrapped) Name() string { return w.inner.Name() }
49+
50+
// Close delegates to the backend, swallowing any panic so teardown of one
51+
// service never crashes on a misbehaving emitter.
52+
func (w wrapped) Close() (err error) {
53+
defer func() {
54+
if r := recover(); r != nil {
55+
err = nil // fail open: teardown is best-effort, never panic upward
56+
}
57+
}()
58+
return w.inner.Close()
59+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package analyticsevent
2+
3+
import (
4+
"context"
5+
"sync"
6+
"testing"
7+
)
8+
9+
// recorder is a test Emitter that captures everything it sees AFTER the wrapper
10+
// sanitized it, so tests can assert the wrapper applied Sanitize.
11+
type recorder struct {
12+
mu sync.Mutex
13+
events []capturedEvent
14+
panicOn string // eventType to panic on (to exercise fail-open)
15+
closed bool
16+
closeErr error
17+
}
18+
19+
type capturedEvent struct {
20+
eventType string
21+
attrs map[string]any
22+
}
23+
24+
func (r *recorder) Record(_ context.Context, eventType string, attrs map[string]any) {
25+
if eventType == r.panicOn {
26+
panic("backend blew up")
27+
}
28+
r.mu.Lock()
29+
defer r.mu.Unlock()
30+
r.events = append(r.events, capturedEvent{eventType, attrs})
31+
}
32+
func (r *recorder) Name() string { return "recorder" }
33+
func (r *recorder) Close() error {
34+
r.closed = true
35+
return r.closeErr
36+
}
37+
func (r *recorder) last() capturedEvent {
38+
r.mu.Lock()
39+
defer r.mu.Unlock()
40+
if len(r.events) == 0 {
41+
return capturedEvent{}
42+
}
43+
return r.events[len(r.events)-1]
44+
}
45+
func (r *recorder) count() int {
46+
r.mu.Lock()
47+
defer r.mu.Unlock()
48+
return len(r.events)
49+
}
50+
51+
func TestWrap_SanitizesBeforeBackend(t *testing.T) {
52+
rec := &recorder{}
53+
e := Wrap(rec)
54+
e.Record(context.Background(), EventFunnel, map[string]any{
55+
AttrEmail: "secret@user.com",
56+
"password": "nope",
57+
AttrTier: "pro",
58+
})
59+
got := rec.last()
60+
if got.eventType != EventFunnel {
61+
t.Fatalf("eventType = %q, want %q", got.eventType, EventFunnel)
62+
}
63+
if _, ok := got.attrs[AttrEmail]; ok {
64+
t.Error("wrapper did not strip raw email before backend")
65+
}
66+
if _, ok := got.attrs["password"]; ok {
67+
t.Error("wrapper did not strip non-allowlisted key before backend")
68+
}
69+
if got.attrs[AttrEmailHash] != HashEmail("secret@user.com") {
70+
t.Error("wrapper did not hash email before backend")
71+
}
72+
if got.attrs[AttrTier] != "pro" {
73+
t.Error("wrapper dropped an allowlisted key")
74+
}
75+
}
76+
77+
func TestWrap_FailOpenOnPanic(t *testing.T) {
78+
rec := &recorder{panicOn: EventFunnel}
79+
e := Wrap(rec)
80+
// Must NOT panic into the caller.
81+
defer func() {
82+
if r := recover(); r != nil {
83+
t.Fatalf("wrapper let backend panic escape: %v", r)
84+
}
85+
}()
86+
e.Record(context.Background(), EventFunnel, nil)
87+
// A subsequent non-panicking event still works (wrapper isn't poisoned).
88+
e.Record(context.Background(), EventChurnSignal, nil)
89+
if rec.count() != 1 {
90+
t.Fatalf("expected 1 captured (the non-panicking) event, got %d", rec.count())
91+
}
92+
}
93+
94+
func TestWrap_DropsEmptyEventType(t *testing.T) {
95+
rec := &recorder{}
96+
Wrap(rec).Record(context.Background(), "", map[string]any{AttrTier: "pro"})
97+
if rec.count() != 0 {
98+
t.Fatalf("empty eventType should be dropped, got %d events", rec.count())
99+
}
100+
}
101+
102+
func TestWrap_NilBackendIsNoop(t *testing.T) {
103+
e := Wrap(nil)
104+
if e.Name() != BackendNoop {
105+
t.Fatalf("Wrap(nil).Name() = %q, want %q", e.Name(), BackendNoop)
106+
}
107+
// Must not panic.
108+
e.Record(context.Background(), EventFunnel, map[string]any{AttrTier: "pro"})
109+
if err := e.Close(); err != nil {
110+
t.Fatalf("Wrap(nil).Close() = %v, want nil", err)
111+
}
112+
}
113+
114+
func TestWrap_Idempotent(t *testing.T) {
115+
rec := &recorder{}
116+
once := Wrap(rec)
117+
twice := Wrap(once)
118+
if once != twice {
119+
t.Fatal("Wrap is not idempotent: Wrap(Wrap(e)) != Wrap(e)")
120+
}
121+
}
122+
123+
func TestWrap_CloseDelegatesAndRecoversPanic(t *testing.T) {
124+
rec := &recorder{}
125+
if err := Wrap(rec).Close(); err != nil {
126+
t.Fatalf("Close = %v, want nil", err)
127+
}
128+
if !rec.closed {
129+
t.Fatal("Close did not delegate to backend")
130+
}
131+
132+
// A panicking Close is swallowed (returns nil).
133+
pc := &panicCloser{}
134+
if err := Wrap(pc).Close(); err != nil {
135+
t.Fatalf("panicking Close should be swallowed to nil, got %v", err)
136+
}
137+
}
138+
139+
type panicCloser struct{ recorder }
140+
141+
func (*panicCloser) Close() error { panic("close blew up") }
142+
143+
func TestWrap_NameTransparent(t *testing.T) {
144+
if Wrap(&recorder{}).Name() != "recorder" {
145+
t.Fatal("wrapper should be transparent for Name()")
146+
}
147+
}

analyticsevent/events.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package analyticsevent
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
// Well-known custom event types. Every emit site MUST use one of these
9+
// constants, never an inline string literal (repo convention: no scattered
10+
// strings; CLAUDE.md rule 16: a single source for each contract token). New
11+
// Relic indexes events by eventType, so a typo creates a silent second event
12+
// table that no dashboard queries.
13+
const (
14+
// EventFunnel is the conversion-funnel event emitted at each step of the
15+
// acquisition journey (landing -> provision -> claim -> paid). Faceted by
16+
// AttrFunnelStep + AttrActor to compute anon->claimed and claimed->paid.
17+
EventFunnel = "InstantFunnel"
18+
19+
// EventFlowTest is one synthetic flow-test result per UI/API flow per tick,
20+
// pushed by the worker's synthetic prober. Carries cohort="synthetic" so
21+
// every other dashboard can exclude this traffic from real KPIs.
22+
EventFlowTest = "InstantFlowTest"
23+
24+
// EventChurnSignal flags a behavioral churn indicator on a paid team (e.g.
25+
// no activity in N days). Feeds the churn-trend tile (WS4-P5).
26+
EventChurnSignal = "InstantChurnSignal"
27+
28+
// EventAbuseSignal flags an abuse indicator (fingerprint dedup-cap hit,
29+
// quota burn, recycle-seen spike). Feeds the abuse behavioral tile (WS4-P6).
30+
EventAbuseSignal = "InstantAbuseSignal"
31+
)
32+
33+
// Canonical funnel-step values for the AttrFunnelStep attribute on
34+
// [EventFunnel]. Low-cardinality and stable so NRQL FACETs stay clean.
35+
const (
36+
FunnelStepLanding = "landing" // onboarding URL first hit
37+
FunnelStepProvision = "provision" // a resource was provisioned
38+
FunnelStepClaim = "claim" // anonymous -> claimed (account created)
39+
FunnelStepPaid = "paid" // claimed -> paid (subscription active)
40+
)
41+
42+
// Canonical actor classes for the AttrActor attribute (mirrors plan F2's
43+
// actor-classification middleware). Low cardinality (safe NR attribute).
44+
const (
45+
ActorAgentClaude = "agent_claude"
46+
ActorAgentMCP = "agent_mcp"
47+
ActorAgentCurl = "agent_curl"
48+
ActorAgentOther = "agent_other"
49+
ActorHumanDashboard = "human_dashboard"
50+
ActorProber = "prober"
51+
ActorUnknown = "unknown"
52+
)
53+
54+
// Canonical flow-test result values for the AttrResult attribute on
55+
// [EventFlowTest].
56+
const (
57+
ResultPass = "pass"
58+
ResultFail = "fail"
59+
ResultSkip = "skip"
60+
)
61+
62+
// CohortSynthetic is the AttrCohort value every synthetic flow-test event
63+
// carries so real-traffic dashboards can exclude it (WHERE cohort != 'synthetic').
64+
const CohortSynthetic = "synthetic"
65+
66+
// Canonical attribute keys. Emit sites and dashboards (NRQL FACET / WHERE)
67+
// MUST use these exact strings. Every key here that is non-PII is also in
68+
// [AllowedAttributes]; AttrEmail is the ONE PII key and is hashed (never
69+
// emitted raw) by [Sanitize].
70+
const (
71+
// Identity / segmentation (non-PII; allowlisted).
72+
AttrActor = "actor" // agent_* / human_* / prober / unknown
73+
AttrTier = "tier" // anonymous, free, hobby, pro, ...
74+
AttrEnv = "env" // development, production, ...
75+
AttrCohort = "cohort" // "synthetic" or "" for real traffic
76+
AttrTeamID = "teamId" // team UUID (an opaque id, not PII)
77+
AttrResourceToken = "resourceToken" // resource token UUID (opaque id)
78+
AttrFingerprint = "fingerprint" // SHA256(/24+ASN) bucket hash (already hashed)
79+
AttrCommitID = "commitId" // deploy SHA, ties failures to a deploy
80+
AttrServiceName = "serviceName" // emitting service: api / worker / provisioner
81+
82+
// Funnel (non-PII; allowlisted).
83+
AttrFunnelStep = "funnelStep" // landing / provision / claim / paid
84+
85+
// Flow-test (non-PII; allowlisted).
86+
AttrFlow = "flow" // db_new, cache_new, deploy_new, ...
87+
AttrLayer = "layer" // api / ui / e2e
88+
AttrResult = "result" // pass / fail / skip
89+
AttrReason = "reason" // short failure reason (free text, no PII)
90+
AttrLatencyMs = "latencyMs" // observed latency in milliseconds
91+
AttrSyntheticRunID = "syntheticRunId" // groups all flows from one prober tick
92+
93+
// Generic event metadata (non-PII; allowlisted).
94+
AttrService = "service" // free-form sub-service / handler name
95+
AttrReasonCode = "reasonCode" // enum-ish machine reason for churn/abuse signals
96+
97+
// PII — NOT allowlisted as-is. Email under this key is HASHED by Sanitize
98+
// into AttrEmailHash; the raw value is dropped.
99+
AttrEmail = "email"
100+
101+
// AttrEmailHash carries sha256(lower(trim(email)))[:16]. This IS allowlisted
102+
// — it is the only form an email may take in an event.
103+
AttrEmailHash = "emailHash"
104+
)
105+
106+
// FlowTest is the typed payload for an [EventFlowTest] custom event, matching
107+
// the synthetic-prober contract in TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §3.3.
108+
// Use [Emitter] with [FlowTest.Attrs] (or the [RecordFlowTest] helper) instead
109+
// of hand-building the attribute map at each call site.
110+
type FlowTest struct {
111+
// Flow is the flow under test ("db_new", "cache_new", "deploy_new", ...).
112+
Flow string
113+
// Actor is the simulated caller class (one of the Actor* constants).
114+
Actor string
115+
// Tier is the plan tier the synthetic run exercised ("anonymous", "pro", ...).
116+
Tier string
117+
// Layer is which layer was probed ("api", "ui", "e2e").
118+
Layer string
119+
// Result is the outcome (one of the Result* constants).
120+
Result string
121+
// LatencyMs is the observed end-to-end latency in milliseconds.
122+
LatencyMs int64
123+
// Reason is a short, PII-free failure reason (empty on pass).
124+
Reason string
125+
// CommitID is the api /healthz commit_id at run time (ties a failure to the
126+
// deploy that caused it).
127+
CommitID string
128+
// SyntheticRunID groups every flow from one prober tick (UUID).
129+
SyntheticRunID string
130+
}
131+
132+
// Attrs renders a [FlowTest] into the flat attribute map an [Emitter] consumes.
133+
// Cohort is always [CohortSynthetic] for flow tests. Empty fields are omitted so
134+
// an absent value reads as "missing" (not "") in NRQL. The result is already
135+
// allowlist-clean (no PII keys) but still passes through [Sanitize] in Record.
136+
func (f FlowTest) Attrs() map[string]any {
137+
out := make(map[string]any, 9)
138+
putStr(out, AttrFlow, f.Flow)
139+
putStr(out, AttrActor, f.Actor)
140+
putStr(out, AttrTier, f.Tier)
141+
putStr(out, AttrLayer, f.Layer)
142+
putStr(out, AttrResult, f.Result)
143+
putStr(out, AttrReason, f.Reason)
144+
putStr(out, AttrCommitID, f.CommitID)
145+
putStr(out, AttrSyntheticRunID, f.SyntheticRunID)
146+
out[AttrLatencyMs] = f.LatencyMs
147+
out[AttrCohort] = CohortSynthetic
148+
return out
149+
}
150+
151+
// RecordFlowTest is the ergonomic typed helper for the synthetic prober: it
152+
// builds the [EventFlowTest] attribute map from a [FlowTest] and records it.
153+
// Fire-and-forget, same fail-open contract as [Emitter.Record].
154+
func RecordFlowTest(ctx context.Context, e Emitter, f FlowTest) {
155+
if e == nil {
156+
return
157+
}
158+
e.Record(ctx, EventFlowTest, f.Attrs())
159+
}
160+
161+
// putStr sets out[key]=val only when val is non-empty, so callers can build a
162+
// map without "" placeholders polluting NRQL facets.
163+
func putStr(out map[string]any, key, val string) {
164+
if val != "" {
165+
out[key] = val
166+
}
167+
}
168+
169+
// nowUnixMilli is a package var so tests can pin the clock if a future event
170+
// needs a server-stamped timestamp. Unused by the current event set (NR stamps
171+
// its own timestamp), retained as the single time source if one is added.
172+
var nowUnixMilli = func() int64 { return time.Now().UnixMilli() }

0 commit comments

Comments
 (0)