mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 08:02:04 +00:00
272 lines
8.4 KiB
Go
272 lines
8.4 KiB
Go
// 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...)
|
|
})
|
|
}
|