Merge pull request #364 from warmbly/feat/cookieless-posthog-analytics

Cookieless PostHog analytics for the hosted properties, with first-party signup attribution
This commit is contained in:
Matthew Meszaros
2026-09-07 06:05:58 -07:00
committed by GitHub
41 changed files with 1853 additions and 24 deletions
+59 -1
View File
@@ -124,6 +124,26 @@ const columns: Column<AdminOrgListItem>[] = [
),
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 ? (
<div className="flex flex-col leading-tight" title={[o.utm_campaign, o.landing_path].filter(Boolean).join(" · ")}>
<span className="text-xs">{o.utm_source || o.landing_path || "—"}</span>
{o.utm_medium && <span className="text-[10px] text-muted-foreground">{o.utm_medium}</span>}
</div>
) : (
<span className="text-xs text-muted-foreground">direct</span>
),
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<number | undefined>();
const [memMax, setMemMax] = useState<number | undefined>();
@@ -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() {
<FilterGroup label="Signed up">
<DateRangeFilter value={created} onChange={setCreated} />
</FilterGroup>
<FilterGroup label="Acquisition channel">
<SearchFilter value={utmSource} onChange={setUtmSource} placeholder="utm_source…" />
<div className="mt-2">
<SearchFilter value={utmMedium} onChange={setUtmMedium} placeholder="utm_medium…" />
</div>
<div className="mt-2 flex flex-col gap-2">
{/* Mutually exclusive: the backend resolves both-at-once
by ignoring one, which would leave a filter switched
on that is doing nothing. */}
<ToggleFilter
checked={hasAcquisition}
onChange={(v) => { setHasAcquisition(v); if (v) setNoAcquisition(false); }}
label="Has acquisition data"
/>
<ToggleFilter
checked={noAcquisition}
onChange={(v) => { setNoAcquisition(v); if (v) setHasAcquisition(false); }}
label="No acquisition data (direct)"
/>
</div>
</FilterGroup>
<FilterGroup label="Flags">
<div className="flex flex-col gap-2">
<ToggleFilter checked={hasOverrides} onChange={setHasOverrides} label="Has custom limits" />
+12
View File
@@ -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;
+27
View File
@@ -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.
+10
View File
@@ -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 }
@@ -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 |
@@ -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
+6
View File
@@ -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 {
+80 -7
View File
@@ -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) {
+16 -2
View File
@@ -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
}
+39
View File
@@ -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 }
+2 -2
View File
@@ -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 {
+5
View File
@@ -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,
+2
View File
@@ -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) {}
+67
View File
@@ -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()
+22 -1
View File
@@ -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"))
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS public.organization_acquisition;
@@ -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.';
+23
View File
@@ -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"`
+138
View File
@@ -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
}
@@ -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")
}
}
+4
View File
@@ -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"`
}
@@ -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...)
})
}
@@ -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)
}
}
@@ -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)
}
})
}
+98 -4
View File
@@ -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
}
+181
View File
@@ -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("<token>", {
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": "<token>",
"event": "signup_completed",
"distinct_id": "$posthog_cookieless",
"properties": {
"$cookieless_mode": true,
"$raw_user_agent": "<the browser's UA>",
"$ip": "<the browser's 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": <https://posthog.com/tutorials/cookieless-tracking>
- PostHog, "Controlling data collection": <https://posthog.com/docs/privacy/data-collection>
- PostHog, capture API reference: <https://posthog.com/docs/api/capture>
- 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): <https://posthog.com/docs/web-analytics/bot-detection>
- CNIL, "Cookies : solutions pour les outils de mesure d'audience" (the exemption conditions quoted above): <https://www.cnil.fr/fr/cookies-solutions-pour-les-outils-de-mesure-daudience>
+83
View File
@@ -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) => (
<script type="application/ld+json" is:inline set:html={JSON.stringify(schema)} />
))}
<!--
Cookieless analytics. Nothing is written to this browser: no cookie, no
localStorage, no sessionStorage, and so no consent banner. The visitor is
derived on PostHog's side from a daily-rotated salt plus IP, root domain
and user agent, and the salt is deleted at the end of the day.
That only holds if nobody is ever identified, which is what
person_profiles: 'never' enforces. Autocapture and session recording stay
off: this is a marketing site, page views are the question.
The root domain is one of the hash inputs, so a visit here and the signup
on app.warmbly.com are one visitor within a day, with no cross-domain
wiring on our side.
-->
{posthogKey && (
<script is:inline define:vars={{ posthogKey, posthogHost }}>
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
window.posthog.init(posthogKey, {
api_host: posthogHost,
cookieless_mode: 'always',
person_profiles: 'never',
autocapture: false,
capture_pageview: true,
disable_session_recording: true,
respect_dnt: true,
});
</script>
)}
<!--
Carry the landing page and any UTM parameters through to the dashboard's
signup form, in the query string and nowhere else. The dashboard reads
them at signup and the backend records them once on the new organization,
so channel attribution survives the daily salt rotation without anything
being stored in this browser.
Done here rather than in each CTA because there are forty of these links
across the site and a new one should not have to remember.
-->
<script is:inline>
(function () {
// This script sits in <head>, so the anchors it rewrites do not exist
// yet. Wait for the parsed document before touching any of them.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', decorate);
} else {
decorate();
}
function decorate() {
var params = new URLSearchParams(location.search);
var carried = new URLSearchParams();
['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'].forEach(function (name) {
var value = params.get(name);
if (value) carried.set(name, value.slice(0, 255));
});
carried.set('wb_lp', location.pathname.slice(0, 255));
if (document.referrer) {
try {
var host = new URL(document.referrer).hostname;
if (host && host !== location.hostname) carried.set('wb_ref', host);
} catch (e) { /* an unparseable referrer is simply not recorded */ }
}
document.querySelectorAll('a[href*="/register"]').forEach(function (link) {
try {
var url = new URL(link.href);
carried.forEach(function (value, name) {
if (!url.searchParams.has(name)) url.searchParams.set(name, value);
});
link.href = url.toString();
} catch (e) { /* leave a link we cannot parse exactly as it was */ }
});
}
})();
</script>
<!-- Phone diagnostics: ?noshell / ?noanim / ?nogl switch heavy subsystems off (global.css, GrainBackdrop). -->
<script is:inline>
var diagParams = new URLSearchParams(location.search);
+17 -3
View File
@@ -66,6 +66,20 @@ const sections = [
<li>Logs, error reports, and session events generated by your use of the Service.</li>
<li>Limited diagnostic information such as user agent, IP address, and request timing, retained for security and abuse prevention.</li>
</ul>
<h3>Website and product analytics</h3>
<p>
We measure visits to warmbly.com and use of the dashboard with PostHog, in its cookieless mode. Nothing is stored in your browser for this: no cookie, no local storage, no session storage, which is why you have not been asked to accept any. PostHog derives a visitor count on its own servers from a hash of a daily-rotated secret, your IP address, your browser's user agent and the site's domain. The secret is deleted at the end of each day, so the hash cannot be carried from one day to the next and cannot be turned back into you. We never call PostHog's identify function, and no analytics event we send carries your name, your email address, your account id or your organization id.
</p>
<p>
We do not use session recording, heatmaps or surveys. A self-hosted Warmbly instance loads none of this.
</p>
<h3>How you found us</h3>
<p>
When you create an account, we record how you arrived: the page on warmbly.com you clicked through from, the site that referred you, and any campaign parameters in the link (the standard <code>utm_source</code>, <code>utm_medium</code>, <code>utm_campaign</code>, <code>utm_term</code> and <code>utm_content</code>). These are read from the sign-up link's address at the moment you sign up, stored once on your organization, and never updated afterwards. Nothing is stored in your browser beforehand.
</p>
<p>
We keep this because the cookieless measurement above deliberately cannot answer it, and knowing which channels bring customers is how we decide what to build and where to spend. It is part of your account record, so it is exported and deleted with the rest of your data.
</p>
</section>
<section id="use" class="scroll-mt-24">
@@ -85,8 +99,8 @@ const sections = [
<h2>05. Legal bases for processing</h2>
<ul>
<li><strong>Contract.</strong> Processing necessary to deliver the Service you have signed up for.</li>
<li><strong>Legitimate interest.</strong> Processing necessary to keep the platform secure, to prevent abuse, and to improve our product.</li>
<li><strong>Consent.</strong> Where required, for optional features such as product analytics or marketing emails.</li>
<li><strong>Legitimate interest.</strong> Processing necessary to keep the platform secure, to prevent abuse, and to improve our product. This is the basis for the aggregate, cookieless website and product measurement described above, and for recording how you found us when you create an account. You can object to processing based on legitimate interest; see your rights below.</li>
<li><strong>Consent.</strong> Where required, for optional features such as marketing emails.</li>
<li><strong>Legal obligation.</strong> Processing required to comply with applicable law, including tax, accounting, and lawful requests from authorities.</li>
</ul>
</section>
@@ -104,7 +118,7 @@ const sections = [
<section id="sharing" class="scroll-mt-24">
<h2>07. Sharing with third parties</h2>
<p>
We share personal data with infrastructure and operational providers that we depend on to run Warmbly. These include AWS for compute, storage, encryption, and queues, Cloudflare for edge security and bot protection, Stripe for billing, and the email providers you connect to Warmbly.
We share personal data with infrastructure and operational providers that we depend on to run Warmbly. These include AWS for compute, storage, encryption, and queues, Cloudflare for edge security and bot protection, Stripe for billing, PostHog (EU region, Frankfurt) for the cookieless aggregate analytics described above, and the email providers you connect to Warmbly.
</p>
<p>
We do not sell personal data. We do not share personal data with advertisers. We will disclose personal data when required by law, when we have your consent, or when necessary to investigate or stop abuse.
+3 -1
View File
@@ -17,7 +17,9 @@ window.__WARMBLY_ENV__ = {
APP_URL: "$(js "${WARMBLY_APP_URL:-}")",
TURNSTILE_KEY: "$(js "${WARMBLY_TURNSTILE_KEY:-}")",
SENTRY_DSN: "$(js "${WARMBLY_SENTRY_DSN:-}")",
SENTRY_ENVIRONMENT: "$(js "${WARMBLY_SENTRY_ENVIRONMENT:-}")"
SENTRY_ENVIRONMENT: "$(js "${WARMBLY_SENTRY_ENVIRONMENT:-}")",
POSTHOG_KEY: "$(js "${WARMBLY_POSTHOG_KEY:-}")",
POSTHOG_HOST: "$(js "${WARMBLY_POSTHOG_HOST:-}")"
};
EOF
+1
View File
@@ -87,6 +87,7 @@
"motion": "^12.34.0",
"next-themes": "^0.4.6",
"papaparse": "^5.5.3",
"posthog-js": "^1.427.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.71.1",
+95
View File
@@ -230,6 +230,9 @@ importers:
papaparse:
specifier: ^5.5.3
version: 5.5.3
posthog-js:
specifier: ^1.427.2
version: 1.427.2(@types/react@19.2.7)(react@19.2.0)
react:
specifier: ^19.1.1
version: 19.2.0
@@ -783,6 +786,15 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@posthog/browser-common@0.7.2':
resolution: {integrity: sha512-ukDmETXy6fTBE4ZpaO8ccscrNz5SvTaUnhQ0Ze2JX/oYILa89rrNwqUH7lDAuhFKa+NY6EoJ9kZMuOvuu4WwLQ==}
'@posthog/core@1.50.5':
resolution: {integrity: sha512-afEchuShDaVIoxAIj76kDZQ1DhfesDmgfVp+mtTzsA3wlc8DF5uoz8YjuTjnxOicWkpP5HCDqYidK/1kT125Cg==}
'@posthog/types@1.409.0':
resolution: {integrity: sha512-239umoaZVb2GBaXeEyJpwFvjhrrChJH8NHCwiao23EBSu3NA6EN0MTMoaHSmMEf4yjiXEFFoYJA6FjJAXF5HGA==}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1937,6 +1949,9 @@ packages:
'@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
@@ -2223,6 +2238,9 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
core-js@3.50.0:
resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
@@ -2325,6 +2343,9 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dompurify@3.4.14:
resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==}
dotenv@17.4.2:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
@@ -2470,6 +2491,9 @@ packages:
picomatch:
optional: true
fflate@0.4.9:
resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==}
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@@ -3013,6 +3037,25 @@ packages:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
posthog-js@1.427.2:
resolution: {integrity: sha512-/jRg3ChHuxcHTTJ/xb+IbpXLYxiBKIWdjrsNNqY1tAwjnFoijmz58i4mIR4A2P5MWx+4R7LlpK9vQfBr8v292g==}
peerDependencies:
'@types/react': '>=16.8.0'
react: '>=16.8.0'
peerDependenciesMeta:
'@types/react':
optional: true
react:
optional: true
preact@10.29.8:
resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==}
peerDependencies:
preact-render-to-string: '>=5'
peerDependenciesMeta:
preact-render-to-string:
optional: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -3098,6 +3141,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
query-selector-shadow-dom@1.0.1:
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -3487,6 +3533,9 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
web-vitals@6.2.1:
resolution: {integrity: sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -3991,6 +4040,17 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.19.1
'@posthog/browser-common@0.7.2':
dependencies:
'@posthog/core': 1.50.5
'@posthog/types': 1.409.0
'@posthog/core@1.50.5':
dependencies:
'@posthog/types': 1.409.0
'@posthog/types@1.409.0': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -5088,6 +5148,9 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/trusted-types@2.0.7':
optional: true
'@types/use-sync-external-store@0.0.6': {}
'@typescript-eslint/eslint-plugin@8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)':
@@ -5440,6 +5503,8 @@ snapshots:
cookie@1.1.1: {}
core-js@3.50.0: {}
crelt@1.0.6: {}
cross-spawn@7.0.6:
@@ -5527,6 +5592,10 @@ snapshots:
dom-accessibility-api@0.6.3: {}
dompurify@3.4.14:
optionalDependencies:
'@types/trusted-types': 2.0.7
dotenv@17.4.2: {}
dunder-proto@1.0.1:
@@ -5702,6 +5771,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.5
fflate@0.4.9: {}
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@@ -6176,6 +6247,26 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
posthog-js@1.427.2(@types/react@19.2.7)(react@19.2.0):
dependencies:
'@posthog/browser-common': 0.7.2
'@posthog/core': 1.50.5
'@posthog/types': 1.409.0
core-js: 3.50.0
dompurify: 3.4.14
fflate: 0.4.9
preact: 10.29.8
query-selector-shadow-dom: 1.0.1
web-vitals: 6.2.1
web-vitals-soft-navs: web-vitals@6.2.1
optionalDependencies:
'@types/react': 19.2.7
react: 19.2.0
transitivePeerDependencies:
- preact-render-to-string
preact@10.29.8: {}
prelude-ls@1.2.1: {}
pretty-format@27.5.1:
@@ -6297,6 +6388,8 @@ snapshots:
punycode@2.3.1: {}
query-selector-shadow-dom@1.0.1: {}
queue-microtask@1.2.3: {}
react-dom@19.2.0(react@19.2.0):
@@ -6610,6 +6703,8 @@ snapshots:
dependencies:
xml-name-validator: 5.0.0
web-vitals@6.2.1: {}
webidl-conversions@3.0.1: {}
webidl-conversions@8.0.1: {}
+3
View File
@@ -7,6 +7,9 @@ allowBuilds:
# (which fetches the binary) has to run or the optional source-map upload
# has nothing to invoke.
'@sentry/cli': true
# posthog-js pulls core-js in transitively; its postinstall only prints a
# funding banner, so there is nothing to run.
core-js: false
esbuild: true
# Overrides used to live under `pnpm.overrides` in package.json but
+11 -2
View File
@@ -28,6 +28,7 @@ import beginSSO from "@/lib/api/client/auth/beginSSO";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { captureException } from "@/lib/observability";
import { isEmpty, readAcquisition } from "@/lib/acquisition";
import type Token from "@/lib/api/models/auth/Token";
import {
beginPasskeyLogin,
@@ -214,6 +215,13 @@ export default function LoginPage() {
() => new URLSearchParams(location.search).get("invite") ?? "",
[location.search],
);
// Where this signup came from, read once from the URL so a re-render or a
// history replace cannot lose it. Empty for a direct visit.
const acquisition = useMemo(() => {
const acq = readAcquisition();
return isEmpty(acq) ? undefined : acq;
}, []);
const signupPossible = authConfig.registration === "false" || !!inviteToken;
// Set when the API refuses a signup the screen believed was possible.
const [refusal, setRefusal] = useState<SignupBlock | null>(null);
@@ -550,6 +558,7 @@ export default function LoginPage() {
password: data.password,
turnstile: token,
invite: inviteToken || undefined,
acquisition,
});
// Email verification off means the account already exists and
// is signed in: land in the dashboard.
@@ -647,14 +656,14 @@ export default function LoginPage() {
try {
const res = mode === "signin"
? await loginMutation.mutateAsync({ email, password, turnstile: token })
: await registerMutation.mutateAsync({ email, password, turnstile: token, invite: inviteToken || undefined });
: await registerMutation.mutateAsync({ email, password, turnstile: token, invite: inviteToken || undefined, acquisition });
toast.success("Code resent!");
setSession(res.session ?? "");
} catch (e) {
toast.error(buildError(e as AppError));
}
});
}, [mode, email, password, inviteToken, loginMutation, registerMutation, withCaptcha]);
}, [mode, email, password, inviteToken, acquisition, loginMutation, registerMutation, withCaptcha]);
return (
<div className="relative">
@@ -58,6 +58,7 @@ import {
import SecuritySelect from "@/components/app/emails/SecuritySelect";
import onboardOAuthStart from "@/lib/api/client/app/emails/onboardOAuthStart";
import onboardOAuthFinish from "@/lib/api/client/app/emails/onboardOAuthFinish";
import { capture } from "@/lib/productAnalytics";
import { finishCloudOAuth, startCloudOAuth } from "@/lib/api/client/app/cloudlink/cloudLink";
import { useAdoptCloudMailbox, useCloudWorkspaceMailboxes } from "@/lib/api/hooks/app/cloudlink/useCloudLink";
import useCloudPool from "@/hooks/useCloudPool";
@@ -201,6 +202,7 @@ export default function AddEmailModal() {
finishCloudOAuth(data.session).then((inbox) => {
qc.invalidateQueries({ queryKey: ["emails", "list"] });
qc.invalidateQueries({ queryKey: ["cloud-link"] });
capture("mailbox_connected", { provider: expected.provider, method: "cloud" });
user.setAddEmail(false);
return inbox;
}),
@@ -243,6 +245,7 @@ export default function AddEmailModal() {
void toast.promise(
onboardOAuthFinish(data.code, data.state).then((inbox) => {
qc.invalidateQueries({ queryKey: ["emails", "list"] });
capture("mailbox_connected", { provider: expected.provider, method: "oauth" });
user.setAddEmail(false);
return inbox;
}),
+77
View File
@@ -0,0 +1,77 @@
// Where a signup came from, read from the signup URL's query string.
//
// Nothing is stored in the browser: the marketing site appends these to its
// "start free" links (see site/src/layouts/Layout.astro), the dashboard reads
// them off its own URL at signup, and the backend writes them once onto the new
// organization. No cookie, no localStorage, and nothing captured before someone
// actually signs up.
//
// This is first-party on purpose. Cookieless analytics answers "which page
// converts" for a day, because the identifying salt is rotated daily; this
// answers "which channel pays" for as long as the account exists, and it keeps
// working if the analytics provider is blocked, changed or dropped.
export interface Acquisition {
landing_path?: string;
referrer_host?: string;
utm_source?: string;
utm_medium?: string;
utm_campaign?: string;
utm_term?: string;
utm_content?: string;
}
// The parameters carried across. The five UTM names are the conventional set
// every ad platform and email tool already emits, so nothing has to be taught
// a Warmbly-specific parameter.
export const UTM_PARAMS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"] as const;
// Values are clamped here as well as on the server, so a hand-edited link
// cannot make the signup request enormous.
const MAX_LENGTH = 255;
function clamp(value: string | null): string | undefined {
if (!value) return undefined;
const trimmed = value.trim().slice(0, MAX_LENGTH);
return trimmed || undefined;
}
// readAcquisition reads the current URL. Returns an empty object for a direct
// visit, which is most of them, and the backend then stores nothing.
export function readAcquisition(search: string = window.location.search): Acquisition {
const params = new URLSearchParams(search);
const acquisition: Acquisition = {};
for (const name of UTM_PARAMS) {
const value = clamp(params.get(name));
if (value) acquisition[name] = value;
}
// wb_lp is the landing path the marketing site was on when the visitor
// clicked through. Only a path is accepted: a full URL would carry the
// referring page's own query string, which is not ours to store.
const landing = clamp(params.get("wb_lp"));
if (landing && landing.startsWith("/")) acquisition.landing_path = landing;
// The referrer is reduced to a bare host for the same reason.
const referrer = clamp(params.get("wb_ref")) ?? hostOf(document.referrer);
if (referrer) acquisition.referrer_host = referrer;
return acquisition;
}
// isEmpty reports a direct visit, so the caller can omit the field entirely.
export function isEmpty(acquisition: Acquisition): boolean {
return Object.keys(acquisition).length === 0;
}
function hostOf(url: string): string | undefined {
if (!url) return undefined;
try {
const host = new URL(url).hostname.toLowerCase();
// Our own origin is not a referral.
return host === window.location.hostname ? undefined : host;
} catch {
return undefined;
}
}
@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import startCampaign, { type StartCampaignOptions } from "@/lib/api/client/app/campaigns/startCampaign";
import { capture } from "@/lib/productAnalytics";
export default function useStartCampaign() {
const queryClient = useQueryClient();
@@ -11,6 +12,9 @@ export default function useStartCampaign() {
queryClient.invalidateQueries({
queryKey: ["campaigns"],
})
// Every page that starts a campaign goes through this hook, so the
// event is counted once here rather than at each button.
capture("campaign_launched");
}
})
}
@@ -1,16 +1,32 @@
import addEmail from "@/lib/api/client/app/emails/addEmail";
import type AddEmail from "@/lib/api/models/app/emails/AddEmail";
import { capture } from "@/lib/productAnalytics";
import { useMutation, useQueryClient } from "@tanstack/react-query";
// providerOf labels a manually connected mailbox by the mail host it uses, so
// the product event can say "another Google Workspace mailbox" without the
// event carrying the customer's mail infrastructure. Anything not on this list
// is reported as "other"; the raw host is never sent.
function providerOf(host: string): string {
const h = host.trim().toLowerCase();
if (h.endsWith("google.com") || h.endsWith("gmail.com")) return "google";
if (h.endsWith("outlook.com") || h.endsWith("office365.com") || h.endsWith("microsoft.com")) return "microsoft";
if (h.endsWith("zoho.com") || h.endsWith("zoho.eu")) return "zoho";
if (h.endsWith("yahoo.com")) return "yahoo";
if (h.endsWith("mail.ru") || h.endsWith("yandex.com")) return "other";
return "other";
}
export default function useAddEmail() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (email: AddEmail) => addEmail(email),
onSuccess: () => {
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ["emails", "list"]
})
capture("mailbox_connected", { provider: providerOf(variables.imap.host), method: "imap" });
}
})
}
+5
View File
@@ -1,3 +1,5 @@
import type { Acquisition } from "@/lib/acquisition";
export default interface Register {
email: string,
password: string,
@@ -6,4 +8,7 @@ export default interface Register {
// Team-invitation token. It is what lets a signup through on an
// invite_only instance, so it must survive the whole register flow.
invite?: string
// Where the signup came from, read from this page's query string. Omitted
// for a direct visit, and then nothing is stored server-side either.
acquisition?: Acquisition
}
+4
View File
@@ -18,6 +18,10 @@ export const SENTRY_ENVIRONMENT = runtimeEnv("SENTRY_ENVIRONMENT", import.meta.e
// release the source maps were uploaded under, and a container variable set
// after the bundle was built could not.
export const SENTRY_RELEASE = import.meta.env.VITE_SENTRY_RELEASE ?? "";
// Cookieless product analytics, hosted-only. Empty means the SDK is never
// loaded and no PostHog host is contacted. See lib/productAnalytics.
export const POSTHOG_KEY = runtimeEnv("POSTHOG_KEY", import.meta.env.VITE_POSTHOG_KEY);
export const POSTHOG_HOST = runtimeEnv("POSTHOG_HOST", import.meta.env.VITE_POSTHOG_HOST, "https://eu.i.posthog.com");
export const HUMAN_VERIFICATION_FAIL = "We couldnt verify youre human. Please try the security check again or reload the page.";
export const PASSWORD_FAIL = "The password must be at least 8 characters long and contain both uppercase and lowercase letters, as well as a number."
export const TOKEN_KEY = "auth_token";
+92
View File
@@ -0,0 +1,92 @@
// Cookieless product analytics.
//
// Two rules decide everything in this file.
//
// It is hosted-only. The dashboard image is the same for the hosted service and
// for a self-host, so the key comes from the container-injected runtime config
// and an unset key means the SDK chunk is never fetched and no PostHog host is
// ever contacted. A self-host therefore ships this code path and never runs it.
//
// It is cookieless, so there is no banner. `cookieless_mode: 'always'` stores
// nothing in the browser: no cookie, no localStorage, no sessionStorage. The
// visitor is derived server-side from a daily-rotated salt plus IP, root domain
// and user agent, and the salt is deleted at the end of the day, so there is no
// identifier to consent to. That only holds if we never call identify, which is
// why `person_profiles: 'never'` is set and why no event property below ever
// carries a user id, an organization id or an email.
//
// Session replay stays off deliberately: it would record mailbox and contact
// screens.
import type { CaptureResult, PostHog } from "posthog-js";
import { POSTHOG_HOST, POSTHOG_KEY } from "./information";
let client: PostHog | null = null;
let loading: Promise<void> | null = null;
// initProductAnalytics loads and configures the SDK, once, and only when a key
// is configured. Loaded as its own chunk so an install with no key pays neither
// the bytes nor a request.
export function initProductAnalytics(): void {
if (!POSTHOG_KEY || loading) return;
loading = import("posthog-js")
.then(({ posthog }) => {
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
cookieless_mode: "always",
person_profiles: "never",
// The dashboard is a private tool behind a login. Autocapturing
// every click would ship contact names and subject lines in
// element text; the named events below are deliberate instead.
autocapture: false,
// The dashboard is a single-page app, so page loads happen
// once and every navigation after that is a history change.
// Plain `true` would report one pageview per session.
capture_pageview: "history_change",
disable_session_recording: true,
respect_dnt: true,
before_send: maskIdsInURLs,
});
client = posthog;
})
.catch(() => {
// Blocked or failed: analytics is never a reason the dashboard breaks.
});
}
// Dashboard paths carry record ids (/app/campaigns/<uuid>), and a pageview
// would otherwise ship them as $current_url. They are not personal data, but
// they are identifiers, and the whole point of cookieless mode is that no such
// value exists to join on. Every uuid-shaped segment becomes ":id", which is
// also what makes the pageview report readable: one row per screen instead of
// one row per record.
const UUID_SEGMENT = /\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\/|$)/gi;
function maskIdsInURLs(event: CaptureResult | null): CaptureResult | null {
if (!event?.properties) return event;
for (const key of ["$current_url", "$pathname", "$referrer"] as const) {
const value = event.properties[key];
if (typeof value === "string") {
event.properties[key] = value.replace(UUID_SEGMENT, "/:id");
}
}
return event;
}
// Event is the closed set of product events the dashboard reports. Keeping it a
// union rather than a string means a typo is a build error and the list stays
// readable as the answer to "what do we actually measure".
export type Event =
| "mailbox_connected"
| "campaign_launched";
// capture reports one product event. A no-op when analytics is off.
//
// Properties must stay non-identifying: a provider name or a step count is
// fine, an org id or an email address is not.
export function capture(event: Event, properties?: Record<string, string | number | boolean>): void {
if (!POSTHOG_KEY) return;
// The SDK may still be in flight on a fast first action; dropping the event
// is better than queueing one that arrives without its session.
client?.capture(event, properties);
}
+3
View File
@@ -66,9 +66,12 @@ import NotFound from './app/not-found';
import { Toaster } from '@/components/ui/toaster';
import { initErrorReporting } from "@/lib/observability";
import { initProductAnalytics } from "@/lib/productAnalytics";
// Before the first render, so a boot failure is reported too.
initErrorReporting();
// Off unless the deployment configured a key; a self-host never loads it.
initProductAnalytics();
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"