diff --git a/admin/src/app/dashboard/OrganizationsPage.tsx b/admin/src/app/dashboard/OrganizationsPage.tsx index bc582994..9ac7b951 100644 --- a/admin/src/app/dashboard/OrganizationsPage.tsx +++ b/admin/src/app/dashboard/OrganizationsPage.tsx @@ -124,6 +124,26 @@ const columns: Column[] = [ ), csv: (o) => o.plan_name || "", }, + { + id: "channel", + header: "Channel", + // Where the workspace came from, recorded once at signup. Hidden by + // default: most signups are direct and the column would read empty. + defaultHidden: true, + // "direct" means no acquisition data at all, which is the same thing + // the "No acquisition data" filter selects. A row with only a landing + // path is not direct, so it shows the path rather than falling through. + cell: (o) => + o.utm_source || o.utm_medium || o.utm_campaign || o.landing_path ? ( +
+ {o.utm_source || o.landing_path || "—"} + {o.utm_medium && {o.utm_medium}} +
+ ) : ( + direct + ), + csv: (o) => [o.utm_source, o.utm_medium, o.utm_campaign, o.landing_path].filter(Boolean).join(" | "), + }, { id: "posture", header: "Posture", @@ -208,6 +228,12 @@ export default function OrganizationsPage() { const [ownerBanned, setOwnerBanned] = useState(false); const [hasActiveCampaigns, setHasActiveCampaigns] = useState(false); const [hasEmailAccounts, setHasEmailAccounts] = useState(false); + // Acquisition channel. utmSource/utmMedium match exactly; the two toggles + // split "arrived through a tagged link" from "came in directly". + const [utmSource, setUtmSource] = useState(""); + const [utmMedium, setUtmMedium] = useState(""); + const [hasAcquisition, setHasAcquisition] = useState(false); + const [noAcquisition, setNoAcquisition] = useState(false); // Count ranges const [memMin, setMemMin] = useState(); const [memMax, setMemMax] = useState(); @@ -234,6 +260,7 @@ export default function OrganizationsPage() { const filterKey = JSON.stringify({ query, status, planId, visibility, subStatus, enterprise, hasOverrides, risk, cancelAtPeriodEnd, hasActiveSubscription, noSubscription, ownerBanned, hasActiveCampaigns, hasEmailAccounts, + utmSource, utmMedium, hasAcquisition, noAcquisition, memMin, memMax, mbMin, mbMax, campMin, campMax, created, trialEnd, periodEnd, updated, sort, }); @@ -260,6 +287,10 @@ export default function OrganizationsPage() { owner_banned: ownerBanned || undefined, has_active_campaigns: hasActiveCampaigns || undefined, has_email_accounts: hasEmailAccounts || undefined, + utm_source: utmSource.trim() || undefined, + utm_medium: utmMedium.trim() || undefined, + has_acquisition: hasAcquisition || undefined, + no_acquisition: noAcquisition || undefined, member_count_min: memMin, member_count_max: memMax, email_account_count_min: mbMin, @@ -286,7 +317,7 @@ export default function OrganizationsPage() { const rows = data?.data ?? []; - const bools = [enterprise, hasOverrides, cancelAtPeriodEnd, hasActiveSubscription, noSubscription, ownerBanned, hasActiveCampaigns, hasEmailAccounts]; + const bools = [enterprise, hasOverrides, cancelAtPeriodEnd, hasActiveSubscription, noSubscription, ownerBanned, hasActiveCampaigns, hasEmailAccounts, hasAcquisition, noAcquisition]; const ranges = [[memMin, memMax], [mbMin, mbMax], [campMin, campMax]]; const activeCount = (query ? 1 : 0) + @@ -295,6 +326,8 @@ export default function OrganizationsPage() { (visibility ? 1 : 0) + (subStatus ? 1 : 0) + (risk ? 1 : 0) + + (utmSource ? 1 : 0) + + (utmMedium ? 1 : 0) + bools.filter(Boolean).length + ranges.filter(([a, b]) => a !== undefined || b !== undefined).length + [created, trialEnd, periodEnd, updated].filter(rangeActive).length + @@ -314,6 +347,10 @@ export default function OrganizationsPage() { setOwnerBanned(false); setHasActiveCampaigns(false); setHasEmailAccounts(false); + setUtmSource(""); + setUtmMedium(""); + setHasAcquisition(false); + setNoAcquisition(false); setMemMin(undefined); setMemMax(undefined); setMbMin(undefined); @@ -389,6 +426,27 @@ export default function OrganizationsPage() { + + +
+ +
+
+ {/* Mutually exclusive: the backend resolves both-at-once + by ignoring one, which would leave a filter switched + on that is doing nothing. */} + { setHasAcquisition(v); if (v) setNoAcquisition(false); }} + label="Has acquisition data" + /> + { setNoAcquisition(v); if (v) setHasAcquisition(false); }} + label="No acquisition data (direct)" + /> +
+
diff --git a/admin/src/lib/api/models/admin.ts b/admin/src/lib/api/models/admin.ts index 43d52943..a2424621 100644 --- a/admin/src/lib/api/models/admin.ts +++ b/admin/src/lib/api/models/admin.ts @@ -832,6 +832,12 @@ export interface AdminOrgListItem { campaign_count: number; active_campaigns: number; risk_state?: OrgRiskState | null; + /** Acquisition channel recorded once at signup. Absent for a direct + * signup, which is most of them, and always absent on a self-host. */ + utm_source?: string | null; + utm_medium?: string | null; + utm_campaign?: string | null; + landing_path?: string | null; plan_name?: string | null; plan_public?: boolean | null; is_enterprise: boolean; @@ -968,6 +974,12 @@ export interface AdminOrgSearch { enterprise?: boolean; risk_state?: OrgRiskState | ""; risk_flagged?: boolean; + // Acquisition channel + utm_source?: string; + utm_medium?: string; + utm_campaign?: string; + has_acquisition?: boolean; + no_acquisition?: boolean; // Subscription state subscription_status?: string; cancel_at_period_end?: boolean; diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 1127d76a..6c34e7f8 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -126,6 +126,7 @@ import ( "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/notify" "github.com/warmbly/warmbly/internal/observability" + productanalytics "github.com/warmbly/warmbly/internal/observability/analytics" "github.com/warmbly/warmbly/internal/pkg/captcha" "github.com/warmbly/warmbly/internal/pkg/emailverify" "github.com/warmbly/warmbly/internal/pkg/encrypt" @@ -877,6 +878,16 @@ func main() { // external sign-in resolves accounts by email alone, which is only safe // for issuers that control their own email namespace. authService.WireIdentities(repository.NewIdentityRepository(primaryDB.Pool)) + // Where a signup came from, written onto the new org once it exists. + authService.WireAcquisition(organizationRepository) + + // Server-side product analytics. Nil (and therefore off) unless the + // operator set POSTHOG_KEY, which no self-host does: the events are + // only useful to whoever runs the hosted service. The client is + // cookieless and never sends a user id, an org id or an email. + productAnalytics := productanalytics.New(cfg.LoadPostHogKey(ctx), config.PostHogHost()) + authService.WireAnalytics(productAnalytics, analyticsHostFrom(os.Getenv("APP_URL"))) + stripeService.WireAnalytics(productAnalytics) // Generic OIDC. Discovery runs at boot: an unreachable issuer is a // configuration error worth surfacing now rather than as a login button @@ -2148,6 +2159,22 @@ func main() { } // emailVerifyHeloHost resolves the hostname the pre-send verifier announces in +// analyticsHostFrom reduces APP_URL to a bare hostname. It is one of the three +// inputs to PostHog's cookieless hash, and PostHog reduces it further to the +// registrable root domain, which is what makes a visit to warmbly.com and the +// signup on app.warmbly.com one visitor. +func analyticsHostFrom(appURL string) string { + appURL = strings.TrimSpace(appURL) + if appURL == "" { + return "" + } + u, err := url.Parse(appURL) + if err != nil || u.Hostname() == "" { + return "" + } + return u.Hostname() +} + // EHLO/HELO: the explicit setting first, else the host of APP_URL. Returns "" // when neither is set, which makes the verifier skip the SMTP probe rather than // greet remote servers with a name they will reject. diff --git a/docker-compose.yml b/docker-compose.yml index e09e763c..fe4343db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -216,6 +216,11 @@ x-selfhost-env: &selfhost-env SENTRY_DSN: ${SENTRY_DSN:-} + # Server-side product analytics, hosted-only. Unset (the default, and what + # every self-host has) means no client is built and nothing is ever sent. + POSTHOG_KEY: ${POSTHOG_KEY:-} + POSTHOG_HOST: ${POSTHOG_HOST:-} + services: # ─── infrastructure ─────────────────────────────────────────────────── @@ -573,6 +578,11 @@ services: # Unset means the dashboard initialises no error reporting and contacts # no Sentry host. Point it at your own project to collect browser errors. WARMBLY_SENTRY_DSN: ${WARMBLY_SENTRY_DSN:-} + # Cookieless product analytics, hosted-only. Unset (the default, and what + # every self-host has) means the SDK chunk is never fetched and no + # PostHog host is contacted. + WARMBLY_POSTHOG_KEY: ${WARMBLY_POSTHOG_KEY:-} + WARMBLY_POSTHOG_HOST: ${WARMBLY_POSTHOG_HOST:-} depends_on: backend: { condition: service_healthy } diff --git a/docs/content/docs/development/configuration.mdx b/docs/content/docs/development/configuration.mdx index cf04bdb3..77334fd6 100644 --- a/docs/content/docs/development/configuration.mdx +++ b/docs/content/docs/development/configuration.mdx @@ -397,6 +397,21 @@ An unset DSN means nothing leaves the process and no Sentry host is contacted. T The `admin` panel and the dashboard also tag events with the build they were served from. That value is baked at image build time, because it has to match the release the source maps were uploaded under, so it cannot be changed by a container variable afterwards. +### Product analytics + +These exist for the hosted service and are unset everywhere else. A self-hosted instance loads no analytics and sends no usage data: the installer never asks about them, the `.env` template does not mention them, and a build with none of them set contains no analytics script to block. + +| Variable | Read by | What it does | Default | +|---|---|---|---| +| `POSTHOG_KEY` | backend | Server-side product events (a completed signup, a started subscription). Unset means no client is built and nothing is sent | unset | +| `POSTHOG_HOST` | backend | Capture host. Point it at a self-hosted PostHog if you run one | `https://eu.i.posthog.com` | +| `WARMBLY_POSTHOG_KEY` | web container | Browser analytics for the dashboard, read at container start. Unset means the SDK chunk is never fetched | unset | +| `WARMBLY_POSTHOG_HOST` | web container | Capture host for the browser | `https://eu.i.posthog.com` | +| `PUBLIC_POSTHOG_KEY` | `site/` build | Marketing site analytics. Build-time: unset produces a site with no analytics code in it at all | unset | +| `PUBLIC_POSTHOG_HOST` | `site/` build | Capture host for the site | `https://eu.i.posthog.com` | + +Everything runs in PostHog's cookieless mode: nothing is stored in the visitor's browser, no `identify` is ever called, and no event property carries a user id, an organization id or an email. The admin panel gets none of it and never will; it is the operator surface and it ships to self-hosters. + ### Push | Variable | What it does | Default | diff --git a/docs/content/docs/development/data-control.mdx b/docs/content/docs/development/data-control.mdx index e58c62a0..b9eb1fd2 100644 --- a/docs/content/docs/development/data-control.mdx +++ b/docs/content/docs/development/data-control.mdx @@ -223,6 +223,14 @@ Set one and that service reports to whatever Sentry Cloud project, self-hosted S The `warmbly` CLI runs on your own machine and reports nowhere, ever. It has no DSN to set. +### Usage analytics + +There are none. A self-hosted instance loads no analytics script and sends no usage data, not aggregated, not anonymised, not "to help us improve the product". + +The hosted service at warmbly.com does measure its own marketing site and dashboard, and the code for that ships in the same images you run. It is inert without a key: the installer never asks for one, the `.env` template does not mention it, and a build without one contains no analytics script for you to block. If you want to check, `grep posthog` the served assets of your own dashboard. + +If you do want product analytics on your own instance, the variables are on [configuration](/development/configuration/#product-analytics) and they accept a self-hosted PostHog. That is your decision to make, and nothing about it points at us. + ## See also - [Install](/development/install/): the wizard that asks all of this up front diff --git a/internal/app/auth/model.go b/internal/app/auth/model.go index d7e5d74c..ceddfcbb 100644 --- a/internal/app/auth/model.go +++ b/internal/app/auth/model.go @@ -1,5 +1,7 @@ package auth +import "github.com/warmbly/warmbly/internal/models" + type AuthData struct { Email string `json:"email"` Password string `json:"password"` @@ -10,6 +12,10 @@ type AuthData struct { // Invite is the team-invitation token the signup arrived with. It is what // lets an invited person create an account under DISABLE_REGISTRATION=invite_only. Invite string `json:"invite"` + // Acquisition is where the visitor came from, read by the dashboard from + // the signup URL's query string. Recorded once on the new organization and + // never updated. Absent for a direct visit, which is most signups. + Acquisition models.OrgAcquisition `json:"acquisition"` } type ConfirmData struct { diff --git a/internal/app/auth/provision.go b/internal/app/auth/provision.go index 92c41ffa..92ae14bb 100644 --- a/internal/app/auth/provision.go +++ b/internal/app/auth/provision.go @@ -13,6 +13,7 @@ import ( "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/analytics" "github.com/warmbly/warmbly/internal/pkg/signuprisk" ) @@ -30,7 +31,17 @@ type SignupOrigin struct { UserAgent string } -func (s *authService) createAccount(ctx context.Context, address, passwordHash, referralCode, invite string, origin SignupOrigin) (*models.User, *errx.Error) { +// SignupAttribution is everything the signup link carried: who sent the person +// (a referral code or a team invitation) and where they came from. All three +// are captured at RegistrationStart, held on the registration session across +// the emailed code, and applied once the account and its organization exist. +type SignupAttribution struct { + ReferralCode string + Invite string + Acquisition models.OrgAcquisition +} + +func (s *authService) createAccount(ctx context.Context, address, passwordHash string, attr SignupAttribution, origin SignupOrigin) (*models.User, *errx.Error) { email, perr := mail.ParseAddress(address) if perr != nil { return nil, errx.ErrEmail @@ -39,7 +50,7 @@ func (s *authService) createAccount(ctx context.Context, address, passwordHash, // Whether the invitation is the only thing that permitted this signup. // When it is, a failed accept cannot fall through to a personal workspace: // that would turn an invite-gated signup into an unrelated account. - inviteRequired := s.inviteIsLoadBearing(ctx, invite) + inviteRequired := s.inviteIsLoadBearing(ctx, attr.Invite) u, xerr := s.userRepository.CreateUser(ctx, email, passwordHash) if xerr != nil { @@ -57,11 +68,16 @@ func (s *authService) createAccount(ctx context.Context, address, passwordHash, // workspace, no trial of its own. A token that died between start and // confirm only falls through to a personal org when open registration // would have accepted the signup anyway. - if invite != "" && s.organizationService != nil { - if _, err := s.organizationService.AcceptInvitation(ctx, invite, u.ID, u.Email); err == nil { + if attr.Invite != "" && s.organizationService != nil { + if _, err := s.organizationService.AcceptInvitation(ctx, attr.Invite, u.ID, u.Email); err == nil { // An invited account finished signing up just as much as a - // self-serve one; it simply joined an existing workspace. + // self-serve one; it simply joined an existing workspace. It is + // counted here rather than at the end because this path returns + // early, and the count cannot move above the invitation check: a + // failed invitation either refuses or falls through to a + // self-serve signup, and only one of those is a signup. s.notifyOperatorSignup(u, "") + s.countSignup(attr, origin) return u, nil } if inviteRequired { @@ -84,6 +100,17 @@ func (s *authService) createAccount(ctx context.Context, address, passwordHash, } } + // Where the workspace came from, written once and never updated. + // Best-effort: attribution is a reporting nicety and must never be the + // reason a signup fails. + if s.acquisition != nil && org != nil && !attr.Acquisition.Empty() { + acq := attr.Acquisition + acq.OrganizationID = org.ID + if err := s.acquisition.RecordOrganizationAcquisition(ctx, &acq); err != nil { + errs.CaptureException(err) + } + } + // Fold the signup's own risk into the new workspace's posture. A signup // signal alone can only ever reach `watch`, which by definition changes // nothing a customer can feel; it takes several detectors agreeing to @@ -97,13 +124,22 @@ func (s *authService) createAccount(ctx context.Context, address, passwordHash, if err := s.trialService.StartFreeTrialWithOrg(ctx, u.ID, org.ID); err != nil { errs.CaptureException(err) // Don't fail registration if trial creation fails + } else if s.productAnalytics != nil { + // Counted here rather than in the browser because this is where + // the trial actually starts; a self-host with billing off never + // reaches this line and so never reports one. + s.productAnalytics.Capture("trial_started", analytics.Request{ + IP: origin.IP, + UserAgent: origin.UserAgent, + Host: s.analyticsHost, + }, nil) } } // Attribute the signup to a referrer if a referral code rode along. // Best-effort: a bad or self-referral code never fails registration. - if s.referral != nil && org != nil && referralCode != "" { - if xerr := s.referral.AttributeSignup(ctx, referralCode, org.ID, u.ID); xerr != nil { + if s.referral != nil && org != nil && attr.ReferralCode != "" { + if xerr := s.referral.AttributeSignup(ctx, attr.ReferralCode, org.ID, u.ID); xerr != nil { errs.CaptureException(xerr) } } @@ -113,10 +149,47 @@ func (s *authService) createAccount(ctx context.Context, address, passwordHash, workspace = org.Name } s.notifyOperatorSignup(u, workspace) + s.countSignup(attr, origin) return u, nil } +// countSignup records the finished signup in product analytics, carrying the +// channel it came from and nothing that names the person: no user id, no +// organization id, no email. The request's address and user agent are only +// forwarded so PostHog's cookieless hash matches this browser's own events; +// it deletes both once it has hashed them. +func (s *authService) countSignup(attr SignupAttribution, origin SignupOrigin) { + if s.productAnalytics == nil { + return + } + acq := attr.Acquisition.Normalize() + props := map[string]any{} + if acq.UTMSource != "" { + props["utm_source"] = acq.UTMSource + } + if acq.UTMMedium != "" { + props["utm_medium"] = acq.UTMMedium + } + if acq.UTMCampaign != "" { + props["utm_campaign"] = acq.UTMCampaign + } + if acq.LandingPath != "" { + props["landing_path"] = acq.LandingPath + } + if acq.ReferrerHost != "" { + props["referrer_host"] = acq.ReferrerHost + } + props["invited"] = attr.Invite != "" + props["referred"] = attr.ReferralCode != "" + + s.productAnalytics.Capture("signup_completed", analytics.Request{ + IP: origin.IP, + UserAgent: origin.UserAgent, + Host: s.analyticsHost, + }, props) +} + // notifyOperatorSignup raises the operator alert for a finished signup. Both // the invited and the self-serve path go through it so neither can be missed. func (s *authService) notifyOperatorSignup(u *models.User, workspace string) { diff --git a/internal/app/auth/registration.go b/internal/app/auth/registration.go index 62bc393f..53433dbb 100644 --- a/internal/app/auth/registration.go +++ b/internal/app/auth/registration.go @@ -42,7 +42,11 @@ func (s *authService) RegistrationStart(ctx context.Context, data *AuthData, ori // nothing to confirm: create the account now rather than issuing a code // nobody can receive. Every product surveyed defaults self-host to this. if !s.policy.RequireEmailVerification || !s.mailDelivers { - u, err := s.createAccount(ctx, data.Email, passwordHash, data.ReferralCode, data.Invite, origin) + u, err := s.createAccount(ctx, data.Email, passwordHash, SignupAttribution{ + ReferralCode: data.ReferralCode, + Invite: data.Invite, + Acquisition: data.Acquisition, + }, origin) if err != nil { return nil, err } @@ -92,6 +96,12 @@ func (s *authService) RegistrationStart(ctx context.Context, data *AuthData, ori ReferralCode: data.ReferralCode, Invite: data.Invite, } + // Held across the emailed code so the org created at confirm still knows + // which link brought the person here. Normalized now, so the session never + // holds an unclamped value a caller supplied. + if acq := data.Acquisition.Normalize(); !acq.Empty() { + session.Acquisition = &acq + } if err := s.saveRegistrationSession(ctx, sessionID, session, expiresAt); err != nil { return nil, err @@ -147,7 +157,11 @@ func (s *authService) RegistrationConfirm(ctx context.Context, data *ConfirmData return nil, err } - u, cerr := s.createAccount(ctx, token.Email, sess.PasswordHash, sess.ReferralCode, sess.Invite, origin) + attr := SignupAttribution{ReferralCode: sess.ReferralCode, Invite: sess.Invite} + if sess.Acquisition != nil { + attr.Acquisition = *sess.Acquisition + } + u, cerr := s.createAccount(ctx, token.Email, sess.PasswordHash, attr, origin) if cerr != nil { return nil, cerr } diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index c0a1e813..d4528d0f 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -16,6 +16,7 @@ import ( "github.com/warmbly/warmbly/internal/infrastructure/cache" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/notify" + "github.com/warmbly/warmbly/internal/observability/analytics" "github.com/warmbly/warmbly/internal/pkg/captcha" "github.com/warmbly/warmbly/internal/repository" ) @@ -35,6 +36,25 @@ type ReferralAttributor interface { AttributeSignup(ctx context.Context, code string, inviteeOrgID, inviteeUserID uuid.UUID) *errx.Error } +// AcquisitionRecorder stores where a signup came from on the new workspace. +// Satisfied by repository.OrganizationRepository; injected post-construction +// (WireAcquisition) and nil-safe, so a deployment that never wires it simply +// records no attribution. +type AcquisitionRecorder interface { + RecordOrganizationAcquisition(ctx context.Context, acq *models.OrgAcquisition) error +} + +// ProductAnalytics counts a finished signup. Satisfied by +// *analytics.Client; injected post-construction (WireAnalytics) and nil-safe, +// so a deployment with no POSTHOG_KEY counts nothing and calls nothing. +// +// The signup is counted server-side because that is the only place it is +// exact: an ad blocker, a closed tab or a failed beacon all lose the browser's +// version of the event that matters most. +type ProductAnalytics interface { + Capture(name string, req analytics.Request, properties map[string]any) +} + // InstanceSettings is the operator-editable half of the signup policy. // Satisfied by instancesettings.Service; injected post-construction // (WireInstanceSettings) so the auth package needs no import of it (no cycle). @@ -53,6 +73,8 @@ type AuthService interface { // WireReferral attaches the referral attributor (post-construction; nil = no // referral attribution at signup). WireReferral(r ReferralAttributor) + WireAcquisition(r AcquisitionRecorder) + WireAnalytics(a ProductAnalytics, host string) // WireOperatorNotifier attaches the instance-wide operator alert channel // (post-construction; nil = no alerts). @@ -138,6 +160,13 @@ type authService struct { googleIDTokens IDTokenVerifier twofa TwoFAChallenger referral ReferralAttributor + // acquisition records the signup's channel on the new org. Nil-safe. + acquisition AcquisitionRecorder + // productAnalytics counts finished signups. Nil-safe. + productAnalytics ProductAnalytics + // analyticsHost is the dashboard's public hostname, forwarded so PostHog's + // cookieless hash resolves to the same root domain the browser reported. + analyticsHost string // settings is the operator-editable settings document, wired after // construction because it needs the database pool. settings InstanceSettings @@ -171,6 +200,16 @@ func (s *authService) WireIdentities(r repository.IdentityRepository) { s.identi func (s *authService) WireReferral(r ReferralAttributor) { s.referral = r } +// WireAcquisition attaches the store for signup attribution. +func (s *authService) WireAcquisition(r AcquisitionRecorder) { s.acquisition = r } + +// WireAnalytics attaches the product-analytics sink. host is the dashboard's +// public hostname, which is one of the three inputs to the cookieless hash. +func (s *authService) WireAnalytics(a ProductAnalytics, host string) { + s.productAnalytics = a + s.analyticsHost = host +} + // WireOperatorNotifier attaches the operator alert channel. func (s *authService) WireOperatorNotifier(n OperatorNotifier) { s.opsNotify = n } diff --git a/internal/app/auth/signup_origin_test.go b/internal/app/auth/signup_origin_test.go index 85e6fae3..c434cf7b 100644 --- a/internal/app/auth/signup_origin_test.go +++ b/internal/app/auth/signup_origin_test.go @@ -49,7 +49,7 @@ func TestCreateAccountRecordsTheSignupOrigin(t *testing.T) { svc := &authService{userRepository: repo, userService: noopUserService{}} origin := SignupOrigin{IP: "203.0.113.5", UserAgent: "Mozilla/5.0 (test)"} - if _, err := svc.createAccount(context.Background(), "Ada.Lovelace+signup@gmail.com", "hash", "", "", origin); err != nil { + if _, err := svc.createAccount(context.Background(), "Ada.Lovelace+signup@gmail.com", "hash", SignupAttribution{}, origin); err != nil { t.Fatalf("createAccount: %v", err.Message) } @@ -78,7 +78,7 @@ func TestCreateAccountRecordsACleanSignupToo(t *testing.T) { repo := &recordingUserRepo{} svc := &authService{userRepository: repo, userService: noopUserService{}} - if _, err := svc.createAccount(context.Background(), "ada@acme.com", "hash", "", "", SignupOrigin{IP: "203.0.113.9"}); err != nil { + if _, err := svc.createAccount(context.Background(), "ada@acme.com", "hash", SignupAttribution{}, SignupOrigin{IP: "203.0.113.9"}); err != nil { t.Fatalf("createAccount: %v", err.Message) } if repo.recordHits != 1 { diff --git a/internal/app/orgtransfer/spec.go b/internal/app/orgtransfer/spec.go index e6df3ba9..45fe32fb 100644 --- a/internal/app/orgtransfer/spec.go +++ b/internal/app/orgtransfer/spec.go @@ -110,6 +110,11 @@ const ( // merged onto the destination workspace rather than inserted as a row. var Tables = []Table{ // ---------- core: the workspace itself ---------- + { + Name: "organization_acquisition", Group: models.OrgDataGroupCore, + Scope: scopeOrg, + Note: "Where the workspace came from, recorded once at signup. It travels because it is the workspace's own record; the destination never rewrites it.", + }, { Name: "organization_roles", Group: models.OrgDataGroupCore, Scope: scopeOrg, diff --git a/internal/app/stripe/disabled.go b/internal/app/stripe/disabled.go index 8628cbc1..a780fb41 100644 --- a/internal/app/stripe/disabled.go +++ b/internal/app/stripe/disabled.go @@ -78,4 +78,6 @@ func (d *disabledService) AutoTopUpCredits(_ context.Context, _ uuid.UUID, _ str func (d *disabledService) WireReferral(_ ReferralRewarder) {} +func (d *disabledService) WireAnalytics(_ ProductAnalytics) {} + func (d *disabledService) WireCredits(_ CreditGranter, _ AuditLogger) {} diff --git a/internal/app/stripe/service.go b/internal/app/stripe/service.go index 09a2c027..61651ba1 100644 --- a/internal/app/stripe/service.go +++ b/internal/app/stripe/service.go @@ -28,6 +28,7 @@ import ( "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/observability/analytics" "github.com/warmbly/warmbly/internal/repository" ) @@ -86,6 +87,10 @@ type StripeService interface { // referral hooks in the webhook flow are skipped). WireReferral(r ReferralRewarder) + // WireAnalytics attaches the product-analytics sink (post-construction; + // nil = money events are not counted, which is the self-host default). + WireAnalytics(a ProductAnalytics) + // WireCredits attaches the AI-credit granter and an audit logger // (post-construction; nil = the credit grant/reset hooks are skipped). WireCredits(g CreditGranter, a AuditLogger) @@ -123,6 +128,13 @@ type OperatorNotifier interface { NotifyOperator(key, title, summary string, fields map[string]string) } +// ProductAnalytics counts a started subscription. Satisfied by +// *analytics.Client; nil-safe, so an instance with no POSTHOG_KEY counts +// nothing. Properties never name an organization or a person. +type ProductAnalytics interface { + Capture(name string, req analytics.Request, properties map[string]any) +} + type stripeService struct { cfg *config.StripeConfig subRepo repository.SubscriptionRepository @@ -133,6 +145,7 @@ type stripeService struct { credits CreditGranter audit AuditLogger opsNotify OperatorNotifier + productAnalytics ProductAnalytics } // WireOperatorNotifier attaches the operator alert channel. @@ -140,6 +153,8 @@ func (s *stripeService) WireOperatorNotifier(n OperatorNotifier) { s.opsNotify = func (s *stripeService) WireReferral(r ReferralRewarder) { s.referral = r } +func (s *stripeService) WireAnalytics(a ProductAnalytics) { s.productAnalytics = a } + func (s *stripeService) WireCredits(g CreditGranter, a AuditLogger) { s.credits = g; s.audit = a } func NewService( @@ -941,6 +956,50 @@ func (s *stripeService) handleSubscriptionCreated(ctx context.Context, event *st return s.handleSubscriptionUpdated(ctx, event) } +// countSubscriptionStarted records a workspace starting to pay. +// +// It fires from the webhook rather than the browser because this is the money +// event and it has to be exact: the customer may have closed the tab on +// Stripe's success page, and an ad blocker would drop the browser's version. +// +// It hangs off the trial-to-paid transition rather than off the +// customer.subscription.created event, so a redelivered or duplicated webhook +// does not report a second start: by the time it arrives the subscription +// already carries a Stripe id and the transition no longer reads as new. That +// is the same guard the paid-worker migration beside it relies on, and it is +// bounded by the same window, which is far narrower than the webhook +// idempotency check that runs before either of them. +// +// Unlike the signup event there is no browser request to join: Stripe called +// us, not the customer. So this lands as its own cookieless visitor and is +// useful as a count and a plan mix, not as the end of a session funnel. +// Nothing here names the organization or the person. +func (s *stripeService) countSubscriptionStarted(ctx context.Context, sub *models.Subscription, plan *models.Plan, stripeSub *stripe.Subscription) { + if s.productAnalytics == nil || stripeSub == nil { + return + } + + props := map[string]any{"status": string(stripeSub.Status)} + if plan != nil { + props["plan"] = plan.Name + } else if sub != nil { + if p, err := s.planRepo.GetByID(ctx, sub.PlanID); err == nil && p != nil { + props["plan"] = p.Name + } + } + if len(stripeSub.Items.Data) > 0 { + if price := stripeSub.Items.Data[0].Price; price != nil { + props["currency"] = string(price.Currency) + props["amount"] = float64(price.UnitAmount) / 100 + if price.Recurring != nil { + props["interval"] = string(price.Recurring.Interval) + } + } + } + + s.productAnalytics.Capture("subscription_started", analytics.Request{}, props) +} + func (s *stripeService) handleSubscriptionUpdated(ctx context.Context, event *stripe.Event) *errx.Error { var stripeSub stripe.Subscription if err := json.Unmarshal(event.Data.Raw, &stripeSub); err != nil { @@ -1003,6 +1062,14 @@ func (s *stripeService) handleSubscriptionUpdated(ctx context.Context, event *st return errx.New(errx.Internal, "failed to update subscription") } + // The workspace has started paying. Reported after the write, so a failed + // update never counts as a start, and keyed off the same transition the + // premium-worker migration below uses, so a redelivered webhook does not + // count a second one. + if wasTrialOnly && sub.HasPaidSubscription() { + s.countSubscriptionStarted(ctx, sub, newPlan, &stripeSub) + } + // Handle worker migrations if workerAssignment service is available if s.workerAssignment != nil { isNowPaid := sub.HasPaidSubscription() diff --git a/internal/config/config_log.go b/internal/config/config_log.go index 55853988..a086e847 100644 --- a/internal/config/config_log.go +++ b/internal/config/config_log.go @@ -1,6 +1,10 @@ package config -import "context" +import ( + "context" + "os" + "strings" +) func (c *Config) LoadSentryDSNApi(ctx context.Context) (string, error) { return c.GetSecret(ctx, "SENTRY_DSN_API", "sentry_dsn/api") @@ -9,3 +13,20 @@ func (c *Config) LoadSentryDSNApi(ctx context.Context) (string, error) { func (c *Config) LoadSentryDSNBackend(ctx context.Context) (string, error) { return c.GetSecret(ctx, "SENTRY_DSN", "sentry_dsn/backend") } + +// LoadPostHogKey returns the project key server-side product analytics posts +// with. Empty means analytics is off, which is the self-host default and the +// default everywhere else: nothing is sent and no host is contacted. +func (c *Config) LoadPostHogKey(ctx context.Context) string { + key, err := c.GetSecret(ctx, "POSTHOG_KEY", "posthog/key") + if err != nil { + return "" + } + return strings.TrimSpace(key) +} + +// PostHogHost is the capture host. Empty falls back to PostHog Cloud EU in the +// analytics package; set it to point at a self-hosted PostHog. +func PostHogHost() string { + return strings.TrimSpace(os.Getenv("POSTHOG_HOST")) +} diff --git a/internal/infrastructure/db/migrations/000133_organization_acquisition.down.sql b/internal/infrastructure/db/migrations/000133_organization_acquisition.down.sql new file mode 100644 index 00000000..ddb740fa --- /dev/null +++ b/internal/infrastructure/db/migrations/000133_organization_acquisition.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.organization_acquisition; diff --git a/internal/infrastructure/db/migrations/000133_organization_acquisition.up.sql b/internal/infrastructure/db/migrations/000133_organization_acquisition.up.sql new file mode 100644 index 00000000..b978adff --- /dev/null +++ b/internal/infrastructure/db/migrations/000133_organization_acquisition.up.sql @@ -0,0 +1,27 @@ +-- Where a workspace came from, captured once at signup and never updated +-- (issue #352). First-party and deliberately minimal: the landing path, the +-- referring host and the UTM parameters the signup link carried. +-- +-- It exists so revenue by channel is a SQL join against subscriptions rather +-- than a vendor report, and so the answer survives an analytics provider being +-- swapped, blocked or dropped. Every column is nullable because most signups +-- carry none of them: a direct visit to the dashboard has no landing page and +-- no campaign. +CREATE TABLE public.organization_acquisition ( + organization_id uuid PRIMARY KEY REFERENCES public.organizations(id) ON DELETE CASCADE, + landing_path text, + referrer_host text, + utm_source text, + utm_medium text, + utm_campaign text, + utm_term text, + utm_content text, + created_at timestamp with time zone NOT NULL DEFAULT now() +); + +-- The one query this table exists for: signups grouped by channel. +CREATE INDEX idx_organization_acquisition_channel + ON public.organization_acquisition (utm_source, utm_medium); + +COMMENT ON TABLE public.organization_acquisition IS + 'Acquisition channel recorded once at signup. Never updated; a row is absent when the signup carried nothing.'; diff --git a/internal/models/admin.go b/internal/models/admin.go index dcab573e..fbf921a7 100644 --- a/internal/models/admin.go +++ b/internal/models/admin.go @@ -815,6 +815,21 @@ type AdminOrgSearch struct { UpdatedAfter *time.Time `form:"updated_after" time_format:"2006-01-02" time_utc:"true"` UpdatedBefore *time.Time `form:"updated_before" time_format:"2006-01-02" time_utc:"true"` + // Acquisition channel. UTMSource/UTMMedium/UTMCampaign match exactly. + // + // HasAcquisition and NoAcquisition select on the presence of an + // acquisition record, not on a UTM tag: a signup that came from a + // marketing page with no campaign parameters has a landing path and + // counts as having acquisition data. "Direct" therefore means "arrived + // with nothing at all", which is what the admin list's Channel column + // shows too. They are mutually exclusive; setting both applies only + // HasAcquisition. + UTMSource string `form:"utm_source"` + UTMMedium string `form:"utm_medium"` + UTMCampaign string `form:"utm_campaign"` + HasAcquisition bool `form:"has_acquisition"` + NoAcquisition bool `form:"no_acquisition"` + Cursor *uuid.UUID `form:"cursor"` Limit int `form:"limit"` SortBy string `form:"sort_by"` // created_at, name, owner_email, member_count, campaign_count, email_account_count @@ -847,6 +862,14 @@ type AdminOrgListItem struct { // which workspaces a detector has acted on without a call per row. RiskState OrgRiskState `json:"risk_state,omitempty"` + // Acquisition channel recorded at signup. Inlined so revenue by channel is + // readable in the table rather than a separate report. Absent for a direct + // signup, which is most of them, and always absent on a self-host. + UTMSource *string `json:"utm_source,omitempty"` + UTMMedium *string `json:"utm_medium,omitempty"` + UTMCampaign *string `json:"utm_campaign,omitempty"` + LandingPath *string `json:"landing_path,omitempty"` + // Plan summary (LEFT JOINed via the org's single active subscription). PlanName *string `json:"plan_name,omitempty"` PlanPublic *bool `json:"plan_public,omitempty"` diff --git a/internal/models/organization_acquisition.go b/internal/models/organization_acquisition.go new file mode 100644 index 00000000..18a73dce --- /dev/null +++ b/internal/models/organization_acquisition.go @@ -0,0 +1,138 @@ +package models + +import ( + "net/url" + "regexp" + "strings" + + "github.com/google/uuid" +) + +// acquisitionFieldMax bounds every captured value. These arrive from a query +// string a stranger controls, so they are clamped before they reach the +// database rather than trusted because a form was involved. +const acquisitionFieldMax = 255 + +// OrgAcquisition is where a workspace came from, recorded once at signup and +// never updated. It is first-party on purpose: the analytics provider answers +// "which page converts" for a day, this answers "which channel pays" for as +// long as the account exists. +type OrgAcquisition struct { + OrganizationID uuid.UUID `json:"organization_id"` + LandingPath string `json:"landing_path,omitempty"` + ReferrerHost string `json:"referrer_host,omitempty"` + UTMSource string `json:"utm_source,omitempty"` + UTMMedium string `json:"utm_medium,omitempty"` + UTMCampaign string `json:"utm_campaign,omitempty"` + UTMTerm string `json:"utm_term,omitempty"` + UTMContent string `json:"utm_content,omitempty"` +} + +// Empty reports whether the signup carried no attribution at all, which is the +// normal case for someone who typed the dashboard's address. Nothing is stored +// for an empty record. +func (a OrgAcquisition) Empty() bool { + return a.LandingPath == "" && a.ReferrerHost == "" && + a.UTMSource == "" && a.UTMMedium == "" && a.UTMCampaign == "" && + a.UTMTerm == "" && a.UTMContent == "" +} + +// Normalize clamps every field to what is safe to store: the landing path is +// reduced to a path (so a full URL, with whatever query string it carried, +// cannot smuggle anything in), the referrer to a bare host, and everything is +// trimmed and truncated. +func (a OrgAcquisition) Normalize() OrgAcquisition { + return OrgAcquisition{ + OrganizationID: a.OrganizationID, + LandingPath: clampField(normalizePath(a.LandingPath)), + ReferrerHost: clampField(normalizeHost(a.ReferrerHost)), + UTMSource: clampField(a.UTMSource), + UTMMedium: clampField(a.UTMMedium), + UTMCampaign: clampField(a.UTMCampaign), + UTMTerm: clampField(a.UTMTerm), + UTMContent: clampField(a.UTMContent), + } +} + +// emailPattern is the one identifier worth catching by shape. These values +// arrive on a query string anybody can write, and an address in a utm_source +// would be stored on the organization and sent as an analytics property, +// breaking the rule that no event names a person. +// +// It is a shape check, not a PII scrubber: it cannot catch a name or an +// account number, and it is not meant to. The defence that carries the weight +// is that these fields are never joined to anything and are only ever read as +// a channel name. +var emailPattern = regexp.MustCompile(`[\w.+-]+@[\w-]+\.[\w.-]+`) + +func clampField(v string) string { + v = strings.TrimSpace(v) + // Redact before truncating, so a value cut mid-address cannot leave a + // recognisable fragment behind. + v = emailPattern.ReplaceAllString(v, "[redacted]") + if len(v) > acquisitionFieldMax { + v = v[:acquisitionFieldMax] + } + // A control character in a value that ends up in a CSV export or a log line + // is never wanted, and no legitimate UTM value has one. + return strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, v) +} + +// normalizePath keeps the path and drops everything else, so "/pricing" and +// "https://warmbly.com/pricing?utm_source=x" both record "/pricing". +// +// The result must be absolute. url.Parse accepts a bare word as a relative +// path, so the check is on what comes out, not on what went in. +func normalizePath(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + if u, err := url.Parse(v); err == nil && strings.HasPrefix(u.Path, "/") { + return u.Path + } + return "" +} + +// hostPattern is what a stored referrer must look like once reduced. Anything +// else is dropped rather than stored: the field's whole purpose is to name a +// site, so a value that is not a hostname has nothing useful in it and might +// have somebody's data in it. +var hostPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`) + +// normalizeHost keeps a bare hostname: no scheme, no path, no port, no query, +// no fragment, lowercase. +// +// A full referrer URL carries the referring page's own query string, which is +// somebody else's data and not ours to store. url.Parse does not help on its +// own: it reads "example.com?u=alice@example.com" as a relative path with an +// empty Hostname, so the reduction below runs on the raw string and the result +// is then checked against hostPattern rather than trusted. +func normalizeHost(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + if u, err := url.Parse(v); err == nil && u.Hostname() != "" { + return keepHost(u.Hostname()) + } + // Bare host, possibly with a scheme, path, port, query or fragment glued on. + v = strings.TrimPrefix(strings.TrimPrefix(v, "https://"), "http://") + for _, sep := range []string{"/", "?", "#", "@", ":"} { + v, _, _ = strings.Cut(v, sep) + } + return keepHost(v) +} + +func keepHost(v string) string { + v = strings.ToLower(strings.TrimSpace(v)) + if !hostPattern.MatchString(v) { + return "" + } + return v +} diff --git a/internal/models/organization_acquisition_test.go b/internal/models/organization_acquisition_test.go new file mode 100644 index 00000000..e96ef784 --- /dev/null +++ b/internal/models/organization_acquisition_test.go @@ -0,0 +1,112 @@ +package models + +import "testing" + +// Acquisition values come off a query string a stranger controls, so the +// normalizer is the boundary: anything that reaches the database has been +// reduced to a path, a bare host, or a clamped scalar. +func TestOrgAcquisitionNormalize(t *testing.T) { + tests := []struct { + name string + in OrgAcquisition + want OrgAcquisition + }{ + { + name: "a full landing URL is reduced to its path", + in: OrgAcquisition{LandingPath: "https://warmbly.com/pricing?utm_source=x&secret=y"}, + want: OrgAcquisition{LandingPath: "/pricing"}, + }, + { + name: "a referrer URL is reduced to a bare lowercase host", + in: OrgAcquisition{ReferrerHost: "HTTPS://News.YCombinator.com/item?id=1"}, + want: OrgAcquisition{ReferrerHost: "news.ycombinator.com"}, + }, + { + name: "a bare host with a port keeps only the host", + in: OrgAcquisition{ReferrerHost: "example.com:8443/path"}, + want: OrgAcquisition{ReferrerHost: "example.com"}, + }, + { + name: "a relative landing path is kept as-is", + in: OrgAcquisition{LandingPath: " /guides/warmup "}, + want: OrgAcquisition{LandingPath: "/guides/warmup"}, + }, + { + name: "a landing value that is neither a URL nor a path is dropped", + in: OrgAcquisition{LandingPath: "pricing"}, + want: OrgAcquisition{}, + }, + { + name: "control characters are stripped from UTM values", + in: OrgAcquisition{UTMSource: "news\nletter\t"}, + want: OrgAcquisition{UTMSource: "newsletter"}, + }, + { + // url.Parse reads this as a relative path with an empty Hostname, + // so the reduction runs on the raw string and the result has to be + // checked rather than trusted. + name: "a query string on a bare referrer host is not stored", + in: OrgAcquisition{ReferrerHost: "example.com?email=alice@example.com"}, + want: OrgAcquisition{ReferrerHost: "example.com"}, + }, + { + name: "a fragment on a bare referrer host is not stored", + in: OrgAcquisition{ReferrerHost: "example.com#alice@example.com"}, + want: OrgAcquisition{ReferrerHost: "example.com"}, + }, + { + name: "userinfo in a bare referrer host is not stored", + in: OrgAcquisition{ReferrerHost: "alice@example.com"}, + want: OrgAcquisition{ReferrerHost: ""}, + }, + { + name: "a referrer that is not a hostname is dropped", + in: OrgAcquisition{ReferrerHost: "not a host"}, + want: OrgAcquisition{}, + }, + { + // These arrive on a query string anybody can write, and an address + // here would be stored on the org and sent as an analytics property. + name: "an email address in a UTM value is redacted", + in: OrgAcquisition{UTMSource: "alice@example.com", UTMCampaign: "launch alice@example.com now"}, + want: OrgAcquisition{UTMSource: "[redacted]", UTMCampaign: "launch [redacted] now"}, + }, + { + name: "an email in a landing path is redacted too", + in: OrgAcquisition{LandingPath: "/invite/bob@example.com"}, + want: OrgAcquisition{LandingPath: "/invite/[redacted]"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.in.Normalize(); got != tt.want { + t.Fatalf("Normalize() = %+v, want %+v", got, tt.want) + } + }) + } +} + +// An oversized value must be clamped rather than rejected: a truncated campaign +// name is still useful, and a failed signup over a long link is not acceptable. +func TestOrgAcquisitionClampsLongValues(t *testing.T) { + long := make([]byte, acquisitionFieldMax*3) + for i := range long { + long[i] = 'a' + } + got := OrgAcquisition{UTMCampaign: string(long)}.Normalize() + if len(got.UTMCampaign) != acquisitionFieldMax { + t.Fatalf("clamped length = %d, want %d", len(got.UTMCampaign), acquisitionFieldMax) + } +} + +// Empty is what decides whether a row is written at all, so a direct signup +// must report empty and any single field must not. +func TestOrgAcquisitionEmpty(t *testing.T) { + if !(OrgAcquisition{}).Empty() { + t.Fatal("a signup that carried nothing should be Empty") + } + if (OrgAcquisition{UTMSource: "newsletter"}).Empty() { + t.Fatal("a signup with a utm_source should not be Empty") + } +} diff --git a/internal/models/token.go b/internal/models/token.go index 3c62399c..342d2484 100644 --- a/internal/models/token.go +++ b/internal/models/token.go @@ -102,4 +102,8 @@ type RegistrationSession struct { // Invite is the invitation token captured at RegistrationStart, re-checked // and redeemed at confirm so the account lands in the inviting org. Invite string `json:"invite,omitempty"` + // Acquisition is where the signup came from, captured at + // RegistrationStart and written onto the org once it exists at confirm. + // Omitted when the signup carried nothing, which is most of them. + Acquisition *OrgAcquisition `json:"acquisition,omitempty"` } diff --git a/internal/observability/analytics/analytics.go b/internal/observability/analytics/analytics.go new file mode 100644 index 00000000..f7be5dd6 --- /dev/null +++ b/internal/observability/analytics/analytics.go @@ -0,0 +1,174 @@ +// 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. +// - It is cookieless. No user id, no organization id and no email is ever a +// property; the event carries only the originating request's IP, user +// agent and host, which PostHog hashes with a daily-rotated salt and then +// deletes. Nothing here identifies a person, and nothing calls identify. +package analytics + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log" + "net/http" + "strings" + "sync" + "time" + + "github.com/warmbly/warmbly/internal/observability/errs" +) + +// DefaultHost is PostHog Cloud EU. EU rather than US because the customers are +// mostly European and the data never needs to leave. +const DefaultHost = "https://eu.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). +const cookielessDistinctID = "$posthog_cookieless" + +// 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 + } + return &Client{ + key: key, + host: host, + http: &http.Client{Timeout: sendTimeout}, + } +} + +// Request is the originating browser request, forwarded so PostHog's cookieless +// hash lands on the same visitor as that browser's own events. Without it a +// server-side event is a second, unrelated visitor and the funnel breaks. +type Request struct { + IP string + UserAgent string + // Host is the site the visitor was on. PostHog reduces it to the + // registrable root domain, so app.warmbly.com and warmbly.com hash alike. + Host string +} + +// Capture sends one event. Properties must never carry a user id, an +// organization id, an email or anything else naming a person: the whole point +// of cookieless mode is that no such value exists to join on. +// +// 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{ + // The flag ingestion keys on (COOKIELESS_MODE_FLAG_PROPERTY). + "$cookieless_mode": true, + } + for k, v := range properties { + props[k] = v + } + // The three hash inputs. PostHog deletes $ip and $raw_user_agent from the + // event once it has hashed them, so neither is retained. + if req.IP != "" { + props["$ip"] = req.IP + } + if req.UserAgent != "" { + props["$raw_user_agent"] = req.UserAgent + } + if req.Host != "" { + props["$host"] = req.Host + } + + body, err := json.Marshal(map[string]any{ + "api_key": c.key, + "event": name, + "distinct_id": cookielessDistinctID, + "properties": props, + "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...) + }) +} diff --git a/internal/observability/analytics/analytics_test.go b/internal/observability/analytics/analytics_test.go new file mode 100644 index 00000000..a9d9a9b5 --- /dev/null +++ b/internal/observability/analytics/analytics_test.go @@ -0,0 +1,126 @@ +package analytics + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// captured is one request the stub PostHog received. +type captured struct { + APIKey string `json:"api_key"` + Event string `json:"event"` + DistinctID string `json:"distinct_id"` + Properties map[string]any `json:"properties"` +} + +// stub stands in for PostHog's capture API and hands back what it was sent. +func stub(t *testing.T) (*httptest.Server, chan captured) { + t.Helper() + got := make(chan captured, 4) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != capturePath { + t.Errorf("posted to %s, want %s", r.URL.Path, capturePath) + } + var c captured + if err := json.NewDecoder(r.Body).Decode(&c); err != nil { + t.Errorf("decode body: %v", err) + } + got <- c + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + return srv, got +} + +func waitFor(t *testing.T, got chan captured) captured { + t.Helper() + select { + case c := <-got: + return c + case <-time.After(3 * time.Second): + t.Fatal("no event reached the capture host") + return captured{} + } +} + +// The contract with PostHog's ingestion: the cookieless sentinel as the +// distinct id, the mode flag, and the three hash inputs. Get any of these +// wrong and the event lands as a separate visitor, or not at all. +func TestCaptureSendsTheCookielessContract(t *testing.T) { + srv, got := stub(t) + c := New("phc_test", srv.URL) + if c == nil { + t.Fatal("a configured key should produce a client") + } + + c.Capture("signup_completed", Request{ + IP: "203.0.113.9", + UserAgent: "Mozilla/5.0", + Host: "app.warmbly.com", + }, map[string]any{"utm_source": "newsletter"}) + + ev := waitFor(t, got) + if ev.APIKey != "phc_test" { + t.Errorf("api_key = %q", ev.APIKey) + } + if ev.Event != "signup_completed" { + t.Errorf("event = %q", ev.Event) + } + if ev.DistinctID != cookielessDistinctID { + t.Errorf("distinct_id = %q, want the cookieless sentinel %q", ev.DistinctID, cookielessDistinctID) + } + for key, want := range map[string]any{ + "$cookieless_mode": true, + "$ip": "203.0.113.9", + "$raw_user_agent": "Mozilla/5.0", + "$host": "app.warmbly.com", + "utm_source": "newsletter", + } { + if ev.Properties[key] != want { + t.Errorf("properties[%q] = %v, want %v", key, ev.Properties[key], want) + } + } +} + +// Nothing in an event may name a person: that is the whole basis for having no +// consent banner. This guards the property set against a future caller quietly +// adding an identifier. +func TestCaptureCarriesNoIdentifiers(t *testing.T) { + srv, got := stub(t) + c := New("phc_test", srv.URL) + c.Capture("trial_started", Request{IP: "203.0.113.9", Host: "app.warmbly.com"}, nil) + + ev := waitFor(t, got) + for _, forbidden := range []string{"user_id", "organization_id", "org_id", "email", "distinct_id", "$user_id"} { + if _, ok := ev.Properties[forbidden]; ok { + t.Errorf("event carries %q, which would defeat cookieless mode", forbidden) + } + } +} + +// No key is the self-host default, and it has to mean no client and no request +// rather than a client that quietly points at PostHog Cloud. +func TestNewWithoutAKeyIsOffAndSafeToCall(t *testing.T) { + for _, key := range []string{"", " "} { + if c := New(key, ""); c != nil { + t.Fatalf("New(%q) returned a client; analytics must be off without a key", key) + } + } + // A nil client is the "never wired" shape and must not panic. + var c *Client + c.Capture("signup_completed", Request{}, nil) +} + +// An unset host must resolve to EU cloud, and a trailing slash must not produce +// a double slash in the capture path. +func TestHostDefaultingAndTrimming(t *testing.T) { + if got := New("k", "").host; got != DefaultHost { + t.Errorf("default host = %q, want %q", got, DefaultHost) + } + if got := New("k", "https://ph.example.com/").host; got != "https://ph.example.com" { + t.Errorf("trailing slash not trimmed: %q", got) + } +} diff --git a/internal/repository/org_acquisition_live_test.go b/internal/repository/org_acquisition_live_test.go new file mode 100644 index 00000000..d81e9ff4 --- /dev/null +++ b/internal/repository/org_acquisition_live_test.go @@ -0,0 +1,181 @@ +package repository + +import ( + "context" + "testing" + + "github.com/warmbly/warmbly/internal/models" +) + +// Acquisition is written once at signup and read back through the admin org +// list, which means a LEFT JOIN, four extra scanned columns and three new +// filters. None of that is exercised by a unit test, so it is checked here +// against a real schema. +// +// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \ +// go test ./internal/repository/ -run LiveOrgAcquisition -v + +func TestLiveOrgAcquisitionRoundTrips(t *testing.T) { + _, pool := liveContactDB(t) + ctx := context.Background() + f := newAdminFixture(t, pool) + repo := NewOrganizationRepository(pool) + + // A workspace with no row reads as no acquisition, which is the normal + // case for a direct signup. + got, err := repo.GetOrganizationAcquisition(ctx, f.org) + if err != nil { + t.Fatalf("GetOrganizationAcquisition before any write: %v", err) + } + if got != nil { + t.Fatalf("an org that carried nothing reads %+v, want nil", got) + } + + acq := &models.OrgAcquisition{ + OrganizationID: f.org, + LandingPath: "https://warmbly.com/pricing?utm_source=x", + ReferrerHost: "HTTPS://News.YCombinator.com/item?id=1", + UTMSource: "newsletter", + UTMMedium: "email", + UTMCampaign: "launch", + } + if err := repo.RecordOrganizationAcquisition(ctx, acq); err != nil { + t.Fatalf("RecordOrganizationAcquisition: %v", err) + } + + got, err = repo.GetOrganizationAcquisition(ctx, f.org) + if err != nil { + t.Fatalf("GetOrganizationAcquisition: %v", err) + } + if got == nil { + t.Fatal("the recorded acquisition did not read back") + } + // Stored normalized: a full URL becomes a path, a referrer becomes a host. + if got.LandingPath != "/pricing" { + t.Errorf("landing_path = %q, want %q", got.LandingPath, "/pricing") + } + if got.ReferrerHost != "news.ycombinator.com" { + t.Errorf("referrer_host = %q, want %q", got.ReferrerHost, "news.ycombinator.com") + } + if got.UTMSource != "newsletter" || got.UTMMedium != "email" || got.UTMCampaign != "launch" { + t.Errorf("utm fields = %q/%q/%q", got.UTMSource, got.UTMMedium, got.UTMCampaign) + } + // Absent fields stay NULL rather than becoming "". + if got.UTMTerm != "" || got.UTMContent != "" { + t.Errorf("absent utm fields read %q/%q, want empty", got.UTMTerm, got.UTMContent) + } + + // The record is the account's origin, not a mutable setting: a second + // signup link must not rewrite where the workspace actually came from. + if err := repo.RecordOrganizationAcquisition(ctx, &models.OrgAcquisition{ + OrganizationID: f.org, + UTMSource: "paid-ads", + }); err != nil { + t.Fatalf("second RecordOrganizationAcquisition: %v", err) + } + got, err = repo.GetOrganizationAcquisition(ctx, f.org) + if err != nil { + t.Fatalf("GetOrganizationAcquisition after the second write: %v", err) + } + if got.UTMSource != "newsletter" { + t.Fatalf("utm_source = %q after a second write; the first signup must win", got.UTMSource) + } + + // A signup that carried nothing writes nothing at all. + if err := repo.RecordOrganizationAcquisition(ctx, &models.OrgAcquisition{OrganizationID: f.org}); err != nil { + t.Fatalf("recording an empty acquisition should be a no-op, got: %v", err) + } +} + +// The admin list joins the table and filters on it. A wrong join or a column +// out of order in the projection would break the scan for every row, acquisition +// or not. +func TestLiveOrgAcquisitionSurfacesInTheAdminList(t *testing.T) { + _, pool := liveContactDB(t) + ctx := context.Background() + f := newAdminFixture(t, pool) + repo := NewOrganizationRepository(pool) + + find := func(t *testing.T, search *models.AdminOrgSearch) *models.AdminOrgListItem { + t.Helper() + search.Query = f.tag + search.Limit = 10 + res, err := repo.SearchOrganizationsForAdmin(ctx, search) + if err != nil { + t.Fatalf("SearchOrganizationsForAdmin: %v", err) + } + for i := range res.Data { + if res.Data[i].ID == f.org { + return &res.Data[i] + } + } + return nil + } + + // Before any acquisition row: the org still lists, with empty channel. + item := find(t, &models.AdminOrgSearch{}) + if item == nil { + t.Fatal("the fixture org did not appear in the admin list") + } + if item.UTMSource != nil { + t.Errorf("utm_source = %v for an org with no acquisition row, want nil", *item.UTMSource) + } + // It counts as a direct signup and not as a tagged one. + if find(t, &models.AdminOrgSearch{NoAcquisition: true}) == nil { + t.Error("an org with no acquisition row should match no_acquisition") + } + if find(t, &models.AdminOrgSearch{HasAcquisition: true}) != nil { + t.Error("an org with no acquisition row should not match has_acquisition") + } + + if err := repo.RecordOrganizationAcquisition(ctx, &models.OrgAcquisition{ + OrganizationID: f.org, + UTMSource: "newsletter", + UTMMedium: "email", + LandingPath: "/pricing", + }); err != nil { + t.Fatalf("RecordOrganizationAcquisition: %v", err) + } + + item = find(t, &models.AdminOrgSearch{}) + if item == nil { + t.Fatal("the fixture org disappeared from the admin list after the join had a row") + } + if item.UTMSource == nil || *item.UTMSource != "newsletter" { + t.Errorf("utm_source = %v, want newsletter", item.UTMSource) + } + if item.UTMMedium == nil || *item.UTMMedium != "email" { + t.Errorf("utm_medium = %v, want email", item.UTMMedium) + } + if item.LandingPath == nil || *item.LandingPath != "/pricing" { + t.Errorf("landing_path = %v, want /pricing", item.LandingPath) + } + // The join must not duplicate the row. + if item.MemberCount != 1 { + t.Errorf("member_count = %d, want 1: the acquisition join should not fan out rows", item.MemberCount) + } + + // And the channel filters select on it. + if find(t, &models.AdminOrgSearch{UTMSource: "newsletter"}) == nil { + t.Error("filtering by utm_source=newsletter did not find the org") + } + if find(t, &models.AdminOrgSearch{UTMSource: "paid-ads"}) != nil { + t.Error("filtering by a different utm_source still found the org") + } + if find(t, &models.AdminOrgSearch{UTMMedium: "email"}) == nil { + t.Error("filtering by utm_medium=email did not find the org") + } + if find(t, &models.AdminOrgSearch{HasAcquisition: true}) == nil { + t.Error("has_acquisition did not find an org that has a row") + } + if find(t, &models.AdminOrgSearch{NoAcquisition: true}) != nil { + t.Error("no_acquisition found an org that has a row") + } + + t.Cleanup(func() { + if _, err := pool.Exec(context.Background(), + `DELETE FROM organization_acquisition WHERE organization_id = $1`, f.org); err != nil { + t.Errorf("cleanup acquisition: %v", err) + } + }) +} diff --git a/internal/repository/pg_organization.go b/internal/repository/pg_organization.go index e355562a..8b988a2d 100644 --- a/internal/repository/pg_organization.go +++ b/internal/repository/pg_organization.go @@ -113,6 +113,13 @@ type OrganizationRepository interface { ListLimitRequestsForOrg(ctx context.Context, orgID uuid.UUID) ([]models.LimitIncreaseRequest, error) ListLimitRequestsForAdmin(ctx context.Context, search *models.AdminLimitRequestSearch) (*models.AdminLimitRequestsResult, error) UpdateLimitRequestStatus(ctx context.Context, id uuid.UUID, status models.LimitRequestStatus, reviewedBy uuid.UUID, notes string) error + + // Acquisition: where the workspace came from, written once at signup. + // The write is ON CONFLICT DO NOTHING because the record is a fact about + // the signup, not a mutable setting: a later visit through a different + // campaign must not rewrite where the account actually came from. + RecordOrganizationAcquisition(ctx context.Context, acq *models.OrgAcquisition) error + GetOrganizationAcquisition(ctx context.Context, orgID uuid.UUID) (*models.OrgAcquisition, error) } type organizationRepository struct { @@ -749,7 +756,13 @@ const adminOrgListColumns = ` (SELECT COUNT(*) FROM email_accounts ea WHERE ea.organization_id = o.id) AS email_account_count, (SELECT COUNT(*) FROM campaigns c WHERE c.organization_id = o.id) AS campaign_count, (SELECT COUNT(*) FROM campaigns c WHERE c.organization_id = o.id AND c.status = 'active') AS active_campaigns, - o.risk_state` + o.risk_state, + oa.utm_source, oa.utm_medium, oa.utm_campaign, oa.landing_path` + +// adminOrgAcquisitionJoin brings in the signup channel. LEFT because most +// workspaces have no row: a direct signup carries nothing to record. +const adminOrgAcquisitionJoin = ` + LEFT JOIN organization_acquisition oa ON oa.organization_id = o.id` // SearchOrganizationsForAdmin lists orgs for the admin panel with cursor // pagination. The cursor is the last seen org id; rows are returned in @@ -854,6 +867,29 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context where += ` AND u.banned_at IS NOT NULL` } + // Acquisition channel + addChannel := func(col, v string) { + if v == "" { + return + } + where += ` AND oa.` + col + ` = $` + itoa(argNum) + args = append(args, v) + argNum++ + } + addChannel("utm_source", search.UTMSource) + addChannel("utm_medium", search.UTMMedium) + addChannel("utm_campaign", search.UTMCampaign) + // Presence of a record, not presence of a UTM tag: a signup that came from + // a marketing page with no campaign parameters still has a landing path, + // and calling that "direct" would be wrong. The admin panel's labels and + // its Channel column use the same definition (models.AdminOrgSearch). + // Mutually exclusive; both set applies only HasAcquisition. + if search.HasAcquisition { + where += ` AND oa.organization_id IS NOT NULL` + } else if search.NoAcquisition { + where += ` AND oa.organization_id IS NULL` + } + // Relationship existence if search.HasActiveCampaigns { where += ` AND EXISTS (SELECT 1 FROM campaigns c WHERE c.organization_id = o.id AND c.status = 'active')` @@ -913,7 +949,7 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context FROM organizations o JOIN users u ON u.id = o.owner_user_id LEFT JOIN subscriptions s ON s.organization_id = o.id - LEFT JOIN plans p ON p.id = s.plan_id + LEFT JOIN plans p ON p.id = s.plan_id` + adminOrgAcquisitionJoin + ` ` + where + ` ` + orderBy + ` LIMIT $` + itoa(argNum) @@ -936,6 +972,7 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context &item.CreatedAt, &item.DeletionScheduledFor, &item.MemberCount, &item.EmailAccountCount, &item.CampaignCount, &item.ActiveCampaigns, &item.RiskState, + &item.UTMSource, &item.UTMMedium, &item.UTMCampaign, &item.LandingPath, &planName, &planPublic, &isEnterprise, ); err != nil { return nil, err @@ -957,7 +994,7 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context } // Total count for the same filter — drop the trailing LIMIT arg. - countQuery := `SELECT COUNT(*) FROM organizations o JOIN users u ON u.id = o.owner_user_id LEFT JOIN subscriptions s ON s.organization_id = o.id LEFT JOIN plans p ON p.id = s.plan_id ` + where + countQuery := `SELECT COUNT(*) FROM organizations o JOIN users u ON u.id = o.owner_user_id LEFT JOIN subscriptions s ON s.organization_id = o.id LEFT JOIN plans p ON p.id = s.plan_id` + adminOrgAcquisitionJoin + ` ` + where var total int64 if err := r.db.QueryRow(ctx, countQuery, args[:len(args)-1]...).Scan(&total); err == nil { result.Pagination.Total = &total @@ -977,7 +1014,7 @@ func (r *organizationRepository) GetOrganizationAdminDetail(ctx context.Context, FROM organizations o JOIN users u ON u.id = o.owner_user_id LEFT JOIN subscriptions s ON s.organization_id = o.id - LEFT JOIN plans p ON p.id = s.plan_id + LEFT JOIN plans p ON p.id = s.plan_id` + adminOrgAcquisitionJoin + ` WHERE o.id = $1` var detail models.AdminOrgDetail @@ -988,6 +1025,7 @@ func (r *organizationRepository) GetOrganizationAdminDetail(ctx context.Context, &detail.CreatedAt, &detail.DeletionScheduledFor, &detail.MemberCount, &detail.EmailAccountCount, &detail.CampaignCount, &detail.ActiveCampaigns, &detail.RiskState, + &detail.UTMSource, &detail.UTMMedium, &detail.UTMCampaign, &detail.LandingPath, &detail.UpdatedAt, &detail.DeletionScheduledAt, &detail.PlanName, &detail.SubscriptionStatus, &isEnterprise, &detail.CurrentPeriodEnd, &detail.TrialEnd, ) @@ -1377,3 +1415,59 @@ func (r *organizationRepository) UpdateLimitRequestStatus(ctx context.Context, i _, err := r.db.Exec(ctx, query, id, status, reviewedBy, notes) return err } + +// RecordOrganizationAcquisition stores where a signup came from. First write +// wins: the row is the account's origin, and nothing later changes it. +func (r *organizationRepository) RecordOrganizationAcquisition(ctx context.Context, acq *models.OrgAcquisition) error { + if acq == nil { + return nil + } + n := acq.Normalize() + if n.Empty() { + return nil + } + _, err := r.db.Exec(ctx, ` + INSERT INTO organization_acquisition ( + organization_id, landing_path, referrer_host, + utm_source, utm_medium, utm_campaign, utm_term, utm_content + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (organization_id) DO NOTHING`, + acq.OrganizationID, + nullIfEmpty(n.LandingPath), nullIfEmpty(n.ReferrerHost), + nullIfEmpty(n.UTMSource), nullIfEmpty(n.UTMMedium), nullIfEmpty(n.UTMCampaign), + nullIfEmpty(n.UTMTerm), nullIfEmpty(n.UTMContent), + ) + return err +} + +// GetOrganizationAcquisition returns nil when the signup carried nothing, +// which is the normal case for a direct visit. +func (r *organizationRepository) GetOrganizationAcquisition(ctx context.Context, orgID uuid.UUID) (*models.OrgAcquisition, error) { + acq := &models.OrgAcquisition{OrganizationID: orgID} + var landingPath, referrerHost, source, medium, campaign, term, content *string + err := r.db.QueryRow(ctx, ` + SELECT landing_path, referrer_host, utm_source, utm_medium, utm_campaign, utm_term, utm_content + FROM organization_acquisition WHERE organization_id = $1`, orgID). + Scan(&landingPath, &referrerHost, &source, &medium, &campaign, &term, &content) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + acq.LandingPath = derefString(landingPath) + acq.ReferrerHost = derefString(referrerHost) + acq.UTMSource = derefString(source) + acq.UTMMedium = derefString(medium) + acq.UTMCampaign = derefString(campaign) + acq.UTMTerm = derefString(term) + acq.UTMContent = derefString(content) + return acq, nil +} + +func derefString(v *string) string { + if v == nil { + return "" + } + return *v +} diff --git a/resources/cookieless-analytics-research.md b/resources/cookieless-analytics-research.md new file mode 100644 index 00000000..e2a00952 --- /dev/null +++ b/resources/cookieless-analytics-research.md @@ -0,0 +1,181 @@ +# Cookieless analytics for the hosted properties + +Research behind issue #352. Everything below was checked against a primary +source, and the source is named. Verified September 2026. + +## The question + +Understand who visits warmbly.com, which pages and channels bring signups, and +what new customers do in the dashboard, with **no cookie banner**: nothing +stored in the visitor's browser and no consent prompt. + +That rules out any tool whose identity model is "write an id into the browser +and read it back". It does not rule out counting visitors, because a visitor +can be counted without being remembered. + +## Options considered + +| Tool | Storage in the browser | Product funnels | Self-host story | Cost at our size | +|---|---|---|---|---| +| **PostHog Cloud EU, cookieless mode** | none in `cookieless_mode: 'always'` | yes | MIT core, self-hostable | 1M events/month free | +| Plausible Cloud | none | no (page analytics only) | AGPL, self-hostable | paid from the first month | +| Fathom | none | no | closed | paid | +| Umami | none | thin | MIT | free self-hosted, we run the server | +| GA4 | cookies, consent required | yes | none | free | +| Matomo (cookieless config) | none when configured | thin | GPL | free self-hosted | + +GA4 is out on the banner requirement alone. Plausible, Fathom and Umami answer +"which page" but not "did this person connect a mailbox and launch a campaign", +which is the half of the question that decides what we build next. Matomo would +work but means running and patching another database. + +**Decision: PostHog Cloud EU in cookieless mode**, plus a first-party +attribution record written at signup for anything that has to outlive a day. + +## How PostHog cookieless mode actually works + +Nothing is written to the browser. The identity is computed on PostHog's +servers as a hash. From `rust/common/cookieless/src/manager.rs` in the PostHog +repository, the hash inputs are: + +- the team id +- a **daily-rotated salt**, held for `SALT_TTL_SECONDS` and then deleted +- the IP address +- the user agent +- the **root domain** (eTLD+1) of the host, via `extract_root_domain` + +Two consequences matter to us: + +1. **The salt is deleted daily**, so the same visitor on two different days is + two different hashes. There is no persistent identifier, and no way to walk + one back to a person. This is what makes the no-banner position defensible. +2. **The hash uses the registrable root domain**, so `warmbly.com` and + `app.warmbly.com` are one visitor and one session within a day, with no + cross-domain wiring on our side. + +### Client configuration + +From PostHog's cookieless tracking tutorial: + +```javascript +posthog.init("", { + cookieless_mode: "always", + api_host: "https://eu.i.posthog.com", +}); +``` + +PostHog's own privacy documentation adds: set `person_profiles: 'never'`, since +"a persistent distinct ID is considered Personal Data under GDPR", which turns +`identify()` into a no-op. `alias` events are dropped at ingestion in this mode. +Cookieless server hash mode has to be enabled in the project settings first. + +### Server-side events + +A backend event has to join the same hash, or it lands as a separate visitor. +The constants are in `rust/common/cookieless/src/constants.rs`: + +```rust +pub const COOKIELESS_SENTINEL_VALUE: &str = "$posthog_cookieless"; +pub const COOKIELESS_MODE_FLAG_PROPERTY: &str = "$cookieless_mode"; +``` + +and `nodejs/src/ingestion/common/cookieless/cookieless-manager.ts` reads +`$raw_user_agent`, `$ip` and `$host` off the event to compute it. So a +server-side capture is: + +```json +{ + "api_key": "", + "event": "signup_completed", + "distinct_id": "$posthog_cookieless", + "properties": { + "$cookieless_mode": true, + "$raw_user_agent": "", + "$ip": "", + "$host": "app.warmbly.com" + } +} +``` + +posted to `https://eu.i.posthog.com/i/v0/e/` (PostHog's capture API reference). +The same file shows the ingester **deletes** `$ip` and `$raw_user_agent` from +the event once the hash is computed, so the raw values are not retained. + +## The legal position + +**This section is engineering notes, not legal advice, and it does not +establish that this deployment may run without a banner. Get a +deployment-specific assessment before relying on it.** + +Two separate questions get conflated here, so keep them apart. + +**Storing or reading anything on the visitor's device.** This is what the +ePrivacy rules (in France, Article 82) attach consent to. `cookieless_mode: +'always'` writes no cookie, no local storage and no session storage, so there +is nothing stored or read to consent to. That is a claim about the mechanism, +and it is one we can verify ourselves rather than take on trust: see the +acceptance checks in the issue. + +**Processing the visitor's IP address and user agent server-side.** This is a +GDPR question and it does not go away because nothing was stored in the +browser. It needs a lawful basis, and whether the resulting hash counts as +personal data is contested rather than settled. PostHog's position is that the +hash cannot be reversed; that is an argument, not a ruling. + +The CNIL's audience-measurement exemption is often cited here and it is worth +being precise about what it actually says, because it is narrower than the +shorthand suggests. Its published conditions are that the tool is used for a +purpose *strictly limited* to measuring the audience of the site, produces +*anonymous statistics only*, does not allow a person to be followed across +different sites or apps, and does not lead to the data being cross-referenced +with other processing or passed to third parties. It says **nothing** about +hashing schemes, salt rotation, or IP-plus-user-agent constructions, and it +notes that some audience-measurement offerings fall outside the exemption +regardless of how they are configured. + +So: the construction above is *designed* against those conditions — no +cross-site identifier, because the hash is scoped to the registrable root +domain; aggregate output only, because `person_profiles: 'never'` makes +`identify` a no-op; and no other processing to join to. Whether a given +deployment qualifies is a judgement about that deployment, and this document +is not that judgement. + +What is clear either way is that acquisition-channel and conversion +measurement are **outside** a "strictly audience measurement" purpose. That is +why the acquisition record below is written up as a deliberate product decision +rather than folded into "analytics": it is kept minimal and first-party, +recorded once at signup as part of the account record, disclosed in the privacy +policy, and it travels with a workspace export and is deleted with the account +like the rest of the customer's data. + +## Why a first-party record as well + +Cookieless mode buys aggregate truth for a day. It cannot answer "the customer +who upgraded in March came from the deliverability guide in January", because +by design nothing joins those two days. + +So the channel is recorded once, at signup, on the organization itself: +`landing_path`, `referrer_host`, and the five UTM fields. Nothing is stored in +the browser before signup — the values ride the query string from the marketing +site to the dashboard's signup page and are read there. Revenue by channel is +then a SQL join against subscriptions, owned by us, and it keeps working if the +analytics provider is blocked, changed or dropped. + +## Explicitly out of scope + +Session replay, heatmaps, surveys and feature flags each store or record more +than a cookieless pageview and would need their own decision. Session replay in +particular would record mailbox and contact screens. + +Self-hosted Warmbly loads none of this: the code path exists in the image, and +without a key it is never initialised. See `docs/content/docs/development/data-control.mdx`. + +## Sources + +- PostHog, "How to do cookieless tracking with PostHog": +- PostHog, "Controlling data collection": +- PostHog, capture API reference: +- PostHog source, `rust/common/cookieless/src/constants.rs` and `manager.rs` +- PostHog source, `nodejs/src/ingestion/common/cookieless/cookieless-manager.ts` +- PostHog, "Bot and traffic detection" (on `$raw_user_agent` for server-side capture): +- CNIL, "Cookies : solutions pour les outils de mesure d'audience" (the exemption conditions quoted above): diff --git a/site/src/layouts/Layout.astro b/site/src/layouts/Layout.astro index bc2e6296..fdd83671 100644 --- a/site/src/layouts/Layout.astro +++ b/site/src/layouts/Layout.astro @@ -29,6 +29,12 @@ const { const extraJsonLd = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : []; +// Cookieless analytics, hosted-only. Unset means no script tag is emitted at +// all: this is a build-time variable, so a build without it produces a site +// with no analytics code in it, not a site that decides at runtime. +const posthogKey = import.meta.env.PUBLIC_POSTHOG_KEY ?? ''; +const posthogHost = import.meta.env.PUBLIC_POSTHOG_HOST ?? 'https://eu.i.posthog.com'; + const siteOrigin = (Astro.site ?? new URL('https://warmbly.com')).toString().replace(/\/$/, ''); const canonical = new URL(Astro.url.pathname, Astro.site ?? 'https://warmbly.com').toString(); const ogImageAbsolute = new URL(ogImage, Astro.site ?? 'https://warmbly.com').toString(); @@ -142,6 +148,83 @@ const websiteJsonLd = { {extraJsonLd.map((schema) => ( + )} + +