|
| 1 | +package handlers |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "sync/atomic" |
| 6 | + |
| 7 | + "instant.dev/common/analyticsevent" |
| 8 | +) |
| 9 | + |
| 10 | +// WS4 behavioral-intelligence funnel events. |
| 11 | +// |
| 12 | +// This file is the api's bridge from the existing Prometheus conversion-funnel |
| 13 | +// counter (instant_conversion_funnel_total — an AGGREGATE count) to the |
| 14 | +// per-entity / per-cohort New Relic custom event (InstantFunnel) that the WS4 |
| 15 | +// observability plan needs for funnel + retention analysis (anon→claim→ |
| 16 | +// provision→paid). The Prometheus counter stays exactly where it is; every |
| 17 | +// funnel emit site now ALSO records an InstantFunnel custom event alongside it. |
| 18 | +// |
| 19 | +// Why a package-level emitter instead of a struct field on every handler: the |
| 20 | +// funnel emit sites live across nine independently-constructed handler structs |
| 21 | +// (DBHandler, CacheHandler, OnboardingHandler, BillingHandler, …), each with |
| 22 | +// its own constructor. The api already shares process-wide observability deps |
| 23 | +// (the `metrics` package globals) the same way. The router wires the concrete |
| 24 | +// emitter ONCE at boot via [SetAnalyticsEmitter]; until then — and in every |
| 25 | +// unit test that doesn't opt in — the default is the no-op emitter, so funnel |
| 26 | +// emission is INERT by default and can NEVER block, slow, or error a request. |
| 27 | +// |
| 28 | +// Fail-open + inert-by-default IS the flag protection: the analyticsevent |
| 29 | +// package wraps every backend so a panic in the sink is swallowed and a nil / |
| 30 | +// unconfigured backend is a silent drop. No separate feature flag is needed — |
| 31 | +// ANALYTICS_BACKEND defaulting to "noop" means this code path does nothing in |
| 32 | +// prod until New Relic is explicitly configured. |
| 33 | + |
| 34 | +// emitterBox wraps the [analyticsevent.Emitter] interface in a single concrete |
| 35 | +// struct type so [analyticsEmitter] (an atomic.Value) always sees ONE concrete |
| 36 | +// type across Stores — atomic.Value panics if successive Store calls pass |
| 37 | +// different concrete types, which a bare interface value would (noop{} vs the |
| 38 | +// factory's wrapped{}). The box is the invariant concrete type. |
| 39 | +type emitterBox struct{ e analyticsevent.Emitter } |
| 40 | + |
| 41 | +// analyticsEmitter holds the process-wide emitter (boxed). atomic.Value so |
| 42 | +// [SetAnalyticsEmitter] (called once at boot, before serving) and the per-request |
| 43 | +// reads in [recordFunnelEvent] are race-free. Defaults to the no-op emitter via |
| 44 | +// the package init below. |
| 45 | +var analyticsEmitter atomic.Value // stores emitterBox |
| 46 | + |
| 47 | +func init() { |
| 48 | + // Inert default: no analytics sink until the router wires one. The no-op |
| 49 | + // emitter drops every event with zero deps and can never error. |
| 50 | + analyticsEmitter.Store(emitterBox{e: analyticsevent.NewNoop()}) |
| 51 | +} |
| 52 | + |
| 53 | +// SetAnalyticsEmitter installs the process-wide analytics emitter. Called once |
| 54 | +// from the router at boot with the emitter built from ANALYTICS_BACKEND (noop by |
| 55 | +// default; the New Relic sink when configured). A nil emitter is ignored so a |
| 56 | +// mis-wire degrades to the existing no-op rather than panicking on first emit. |
| 57 | +func SetAnalyticsEmitter(e analyticsevent.Emitter) { |
| 58 | + if e == nil { |
| 59 | + return |
| 60 | + } |
| 61 | + analyticsEmitter.Store(emitterBox{e: e}) |
| 62 | +} |
| 63 | + |
| 64 | +// getAnalyticsEmitter returns the current process-wide emitter, never nil. |
| 65 | +func getAnalyticsEmitter() analyticsevent.Emitter { |
| 66 | + if box, ok := analyticsEmitter.Load().(emitterBox); ok && box.e != nil { |
| 67 | + return box.e |
| 68 | + } |
| 69 | + return analyticsevent.NewNoop() |
| 70 | +} |
| 71 | + |
| 72 | +// serviceNameAPI is the AttrServiceName value every funnel event from this |
| 73 | +// service carries, so a dashboard can FACET by which service emitted the step. |
| 74 | +const serviceNameAPI = "api" |
| 75 | + |
| 76 | +// Funnel-step values re-exported from analyticsevent so the per-handler emit |
| 77 | +// sites (db/cache/nosql/…/onboarding/billing) reference one in-package constant |
| 78 | +// and don't each need to import common/analyticsevent. These MUST stay equal to |
| 79 | +// the analyticsevent constants — funnelStepsMatchCanonical (in the test) asserts |
| 80 | +// it, and the wire contract (dashboards FACET on these exact strings) depends on |
| 81 | +// it. |
| 82 | +const ( |
| 83 | + funnelStepProvision = analyticsevent.FunnelStepProvision |
| 84 | + funnelStepClaim = analyticsevent.FunnelStepClaim |
| 85 | + funnelStepPaid = analyticsevent.FunnelStepPaid |
| 86 | + funnelStepLanding = analyticsevent.FunnelStepLanding |
| 87 | +) |
| 88 | + |
| 89 | +// recordFunnelEvent emits one [analyticsevent.EventFunnel] custom event for the |
| 90 | +// given funnel step alongside the existing Prometheus counter. It is the single |
| 91 | +// chokepoint every funnel emit site routes through so the attribute set stays |
| 92 | +// uniform and PII-safe. |
| 93 | +// |
| 94 | +// Attributes are intentionally low-cardinality and allowlisted (the |
| 95 | +// analyticsevent wrapper drops anything not on the PII allowlist before the |
| 96 | +// event leaves the process): step, tier, env, service, and — when known — the |
| 97 | +// already-hashed fingerprint bucket (SHA256(/24+ASN), never a raw IP) and team |
| 98 | +// id (an opaque UUID, not PII). Empty values are omitted so an absent field |
| 99 | +// reads as "missing" in NRQL rather than "". |
| 100 | +// |
| 101 | +// FAIL-OPEN: this never returns an error and the wrapper swallows any panic, so |
| 102 | +// a funnel emit can never affect the request path. Callers MUST NOT wrap it in |
| 103 | +// error handling. |
| 104 | +func recordFunnelEvent(ctx context.Context, step string, attrs funnelAttrs) { |
| 105 | + getAnalyticsEmitter().Record(ctx, analyticsevent.EventFunnel, attrs.toMap(step)) |
| 106 | +} |
| 107 | + |
| 108 | +// funnelAttrs is the typed, PII-safe attribute payload for a funnel event. Only |
| 109 | +// these fields can reach an event; the package allowlist is the backstop. |
| 110 | +type funnelAttrs struct { |
| 111 | + // Tier is the plan tier the funnel step occurred at ("anonymous", "free", |
| 112 | + // "pro", …). Low cardinality. |
| 113 | + Tier string |
| 114 | + // Env is the resolved environment ("development", "production", …). |
| 115 | + Env string |
| 116 | + // Fingerprint is the already-hashed SHA256(/24+ASN) anonymous bucket, or "" |
| 117 | + // for an authenticated step. Never a raw IP. |
| 118 | + Fingerprint string |
| 119 | + // TeamID is the owning team UUID (opaque id, not PII), or "" when unknown |
| 120 | + // (e.g. anonymous provisions before a claim). |
| 121 | + TeamID string |
| 122 | +} |
| 123 | + |
| 124 | +// toMap renders funnelAttrs + the step into the flat attribute map the emitter |
| 125 | +// consumes, omitting empty values so NRQL facets stay clean. |
| 126 | +func (a funnelAttrs) toMap(step string) map[string]any { |
| 127 | + out := map[string]any{ |
| 128 | + analyticsevent.AttrFunnelStep: step, |
| 129 | + analyticsevent.AttrServiceName: serviceNameAPI, |
| 130 | + } |
| 131 | + if a.Tier != "" { |
| 132 | + out[analyticsevent.AttrTier] = a.Tier |
| 133 | + } |
| 134 | + if a.Env != "" { |
| 135 | + out[analyticsevent.AttrEnv] = a.Env |
| 136 | + } |
| 137 | + if a.Fingerprint != "" { |
| 138 | + out[analyticsevent.AttrFingerprint] = a.Fingerprint |
| 139 | + } |
| 140 | + if a.TeamID != "" { |
| 141 | + out[analyticsevent.AttrTeamID] = a.TeamID |
| 142 | + } |
| 143 | + return out |
| 144 | +} |
0 commit comments