// Package analytics posts product events to PostHog's capture API. // // It exists for the handful of events that have to be exact — a completed // signup, a started subscription — where the browser is the wrong place to // count from because an ad blocker, a closed tab or a failed request all lose // the event that matters most. // // Two rules shape everything here: // // - It is off unless POSTHOG_KEY is set, which is the self-host default. A // nil *Client is a working no-op, so callers never guard. // - An event names the person it happened to when the caller knows one. The // distinct id is the user's account id, the same id the dashboard // identifies the browser with, so a server-side signup and the session // that led to it are one person in PostHog. Person properties ride on // `$set`, the workspace is a group, and the originating request's IP and // user agent go along so the event is geolocated and attributed to the // right device. A caller with no person to name (a Stripe webhook for a // workspace with no user) falls back to the cookieless hash. package analytics import ( "bytes" "context" "encoding/json" "io" "log" "net" "net/http" "net/url" "strings" "sync" "time" "github.com/warmbly/warmbly/internal/observability/errs" ) // DefaultHost is PostHog Cloud US, where most of the customers are. const DefaultHost = "https://us.i.posthog.com" // capturePath is PostHog's single-event capture endpoint. const capturePath = "/i/v0/e/" // cookielessDistinctID is PostHog's sentinel telling ingestion to derive the // visitor from the daily hash instead of from an id we supply // (COOKIELESS_SENTINEL_VALUE in the PostHog source). Used only when the caller // has no person to name. const cookielessDistinctID = "$posthog_cookieless" // OrganizationGroup is the PostHog group type a workspace is reported under. // The dashboard uses the same name in its `group` call, so the two sides land // on one group. const OrganizationGroup = "organization" // sendTimeout bounds one capture. Analytics must never be why a signup is slow. const sendTimeout = 5 * time.Second // Client posts events. A nil *Client is valid and does nothing. type Client struct { key string host string http *http.Client // warned bounds the failure log to one line per process; see warnOnce. warned sync.Once } // New returns a client, or nil when no key is configured. Returning nil rather // than a disabled client is deliberate: it makes "analytics is off" the same // shape as "analytics was never wired", so there is one path to test. func New(key, host string) *Client { key = strings.TrimSpace(key) if key == "" { return nil } host = strings.TrimRight(strings.TrimSpace(host), "/") if host == "" { host = DefaultHost } // Events name a person, so a plaintext host off this machine hands their // email and address to the network. Not refused: a self-hosted PostHog on // a private network over http is a supported shape, and refusing would // silently turn analytics off. Said once, loudly. if u, err := url.Parse(host); err == nil && u.Scheme == "http" && !isLoopback(u.Hostname()) { log.Printf("product analytics: POSTHOG_HOST %s is plaintext http; identified events (email, name, IP) will cross the network unencrypted", host) } return &Client{ key: key, host: host, http: &http.Client{Timeout: sendTimeout}, } } // isLoopback reports whether the host is this machine, where plaintext is fine. func isLoopback(hostname string) bool { if hostname == "localhost" { return true } ip := net.ParseIP(hostname) return ip != nil && ip.IsLoopback() } // Request is who the event happened to and the browser request it came from. type Request struct { // UserID is the account the event belongs to, and becomes the distinct id. // Empty means nobody is named and the event lands on the cookieless hash. UserID string // Email and Name are set on the person when UserID is given. Email string Name string // OrganizationID puts the event in the workspace's group; OrganizationName // and Plan are set on that group. OrganizationID string OrganizationName string Plan string // SetOnce are person properties written the first time only, which is // what acquisition is: where somebody came from does not change later. SetOnce map[string]any // IP and UserAgent are the originating browser request, forwarded so the // event is geolocated and, without a UserID, so the cookieless hash lands // on the same visitor as that browser's own events. IP string UserAgent string // Host is the site the visitor was on, one of the cookieless hash inputs. // PostHog reduces it to the registrable root domain, so app.warmbly.com // and warmbly.com hash alike. Host string } // Capture sends one event. // // It sends in the background and reports its own failures rather than // returning them: no caller should abandon a signup because an analytics host // was unreachable. func (c *Client) Capture(name string, req Request, properties map[string]any) { if c == nil || name == "" { return } props := map[string]any{} for k, v := range properties { props[k] = v } if req.IP != "" { props["$ip"] = req.IP } if req.UserAgent != "" { props["$raw_user_agent"] = req.UserAgent } if req.Host != "" { props["$host"] = req.Host } distinctID := cookielessDistinctID if req.UserID != "" { distinctID = req.UserID set := map[string]any{} if req.Email != "" { set["email"] = req.Email } if req.Name != "" { set["name"] = req.Name } if len(set) > 0 { props["$set"] = set } if len(req.SetOnce) > 0 { props["$set_once"] = req.SetOnce } if req.OrganizationID != "" { props["$groups"] = map[string]any{OrganizationGroup: req.OrganizationID} } } else { // The flag ingestion keys on (COOKIELESS_MODE_FLAG_PROPERTY). PostHog // deletes $ip and $raw_user_agent from the event once hashed. props["$cookieless_mode"] = true } body, err := json.Marshal(map[string]any{ "api_key": c.key, "event": name, "distinct_id": distinctID, "properties": props, "timestamp": time.Now().UTC().Format(time.RFC3339), }) if err != nil { errs.CaptureException(err) return } go c.post(body) if req.UserID != "" && req.OrganizationID != "" { c.identifyGroup(req) } } // identifyGroup sets the workspace's group properties. PostHog takes them on // a `$groupidentify` event rather than on the event itself. func (c *Client) identifyGroup(req Request) { set := map[string]any{} if req.OrganizationName != "" { set["name"] = req.OrganizationName } if req.Plan != "" { set["plan"] = req.Plan } if len(set) == 0 { return } body, err := json.Marshal(map[string]any{ "api_key": c.key, "event": "$groupidentify", "distinct_id": req.UserID, "properties": map[string]any{ "$group_type": OrganizationGroup, "$group_key": req.OrganizationID, "$group_set": set, }, "timestamp": time.Now().UTC().Format(time.RFC3339), }) if err != nil { errs.CaptureException(err) return } go c.post(body) } func (c *Client) post(body []byte) { defer func() { if r := recover(); r != nil { errs.Recover(r) } }() ctx, cancel := context.WithTimeout(context.Background(), sendTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.host+capturePath, bytes.NewReader(body)) if err != nil { c.warnOnce("cannot build a capture request for %s: %v", c.host, err) return } req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { c.warnOnce("cannot reach the analytics host %s: %v", c.host, err) return } defer resp.Body.Close() // Drained so the connection can be reused; the response body is of no // interest beyond that. _, _ = io.Copy(io.Discard, resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { c.warnOnce("the analytics host %s rejected a capture with %s (check POSTHOG_KEY)", c.host, resp.Status) } } // warnOnce logs the first failure and nothing after it. // // A wrong key or an unreachable host fails on every single event, so logging // each one would bury the instance's real logs under analytics noise. Logging // none of them is worse: a misconfigured key would look exactly like a quiet // week. One line, the first time, is the useful amount. func (c *Client) warnOnce(format string, args ...any) { c.warned.Do(func() { log.Printf("product analytics disabled for this run: "+format, args...) }) }