Repository navigation
Track signups and license purchases in PostHog - #89
Conversation
| returning id`, | ||
| ); | ||
| if (inserted.length === 0) return; | ||
| if (o.user_id) events.push(licensePurchasedEvent(o.user_id, "desktop", o.id)); |
There was a problem hiding this comment.
🟡 Claimed guest purchases never count
A guest Desktop purchase gets no license_purchased event because o.user_id is null at issuance. Later claim_purchases attaches the license without emitting one, so that user's purchase remains uncounted.
Learn more
Desktop licenses can be purchased without an account. Convergence issues the license while its user_id is null, so this event gate does not add a purchase event. The database claim_purchases later attaches guest orders and licenses after the buyer creates a verified account. That path never calls convergence, and a later ingest finds the license already inserted, leaving no path to emit the purchase for the new account.
Example: A guest buys Desktop using an email address. They later create an account with that address and claim the existing key. They get a key but no license_purchased event under their user ID.
Recommended fix: Emit an idempotent purchase event on the claim path when claim_purchases attaches a previously guest-owned Desktop license, using the order's stable insert ID. Ensure the capture happens after the claim commits.
Was this helpful? React with 👍 or 👎 to provide feedback.
| await fetch(`${config.host.replace(/\/$/, "")}/capture/`, { | ||
| method: "POST", | ||
| headers: { "content-type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| api_key: config.key, | ||
| event: event.event, | ||
| distinct_id: event.distinctId, | ||
| properties, | ||
| timestamp: event.timestamp ?? new Date().toISOString(), | ||
| }), | ||
| }); |
There was a problem hiding this comment.
🟡 Failed captures permanently lose purchases
When PostHog rejects a captureEvent request, billing treats the purchase as delivered without checking the response. The committed license blocks another event on replay, permanently undercounting purchases.
Learn more
The webhook commits license issuance and marks its delivery processed before emitAnalytics sends the event. A non-2xx response does not reject fetch, and a network exception is swallowed as well. Reconciliation cannot reconstruct the event because convergeDesktop only emits on insertion and convergePro only emits for the first key. The web signup capture has the same ignored HTTP-response behavior in captureEvent, though the manual backfill can recover signups.
Example: PostHog returns 503 as a paid Desktop webhook processes. The event is lost; a retried webhook is already marked processed, and a reconciler pass finds the key already issued.
Recommended fix: Check res.ok and persist failed outbound events in a durable retry queue keyed by $insert_id; replay them independently of webhook and license deduplication. Apply the same response check to signup capture.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const value = url.searchParams.get(key); | ||
| if (value) extra[key as keyof SignupAttribution] = value; | ||
| } | ||
| Object.assign(props, utmFromAttribution(extra)); |
There was a problem hiding this comment.
🟡 Callback UTMs overwrite first-touch attribution
When a signup callback URL contains UTM fields, Object.assign replaces matching fields from the first-touch cookie. The signup is attributed to the return URL instead of its original campaign.
Learn more
rememberAttribution saves the first landing page's UTM fields in a cookie. The signup event initially reads them, but the callback URL's fields overwrite them through Object.assign. A callback URL is the destination after sign-in, not the first touch; it can carry different campaign parameters.
Example: A visitor first arrives with utm_source=ads, then signs up with a callback URL containing utm_source=email. The user_signed_up event records email, not ads.
Recommended fix: Use callback URL UTMs only for fields absent from the sanitized cookie attribution.
Was this helpful? React with 👍 or 👎 to provide feedback.
| useEffect(() => { | ||
| rememberAttribution(); | ||
| }, []); |
There was a problem hiding this comment.
🟡 SPA campaign visits lose attribution
When a visitor reaches a campaign URL through client-side navigation, rememberAttribution does not rerun. If no attribution cookie exists, the later signup omits that campaign.
Learn more
The root component stays mounted across client-side route changes. Its effect calls rememberAttribution only once; that helper uses the current browser URL and writes a cookie only if it has attribution. If the initial page is / with no UTM, a later SPA visit to a tagged marketing page creates no cookie.
Example: A visitor loads / directly and then follows an in-app link to /download?utm_source=partner before signing up. The signup event has no utm_source.
Recommended fix: Invoke the first-touch recorder on location changes until an attribution cookie exists, without overwriting a valid existing first touch.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (kind === "pro" && s.status === "trialing" && userId) | ||
| touched.events.push(desktopTrialStartedEvent(userId, s.providerSubscriptionId)); |
There was a problem hiding this comment.
🟡 Trials after initial subscription go uncounted
When a stored Pro subscription changes from incomplete to trialing, applySubscription updates it without emitting desktop_trial_started. Only a subscription initially inserted as trialing enters the trial funnel.
Learn more
applySubscription supports later status changes, including a stored incomplete subscription becoming trialing. The new analytics event is added only in the insert branch. No subsequent run of the update branch emits it, so the first actual trial state for that subscription is omitted.
Example: An initial Polar fact stores a Pro subscription as incomplete. A later fact advances it to trialing; the database shows a trial but PostHog has no trial-start event.
Recommended fix: Generate the stable-id event on the first transition to trialing as well as on insertion in that state, then emit after transaction commit.
Was this helpful? React with 👍 or 👎 to provide feedback.
| const method = | ||
| row.provider_id === "github" || row.provider_id === "google" ? row.provider_id : "email"; |
| const [config, session] = await Promise.all([getPublicConfig(), getSession()]); | ||
| return { posthog: config.posthog, userId: session?.user.id ?? null }; |
| scope.background( | ||
| capture(signupEventFromAuthHook(user.id, hookCtx as AuthHookContext, env.authUrl)), | ||
| ); |
There was a problem hiding this comment.
🟨 Opted-out signups still reach PostHog
When a visitor opts out, captureEvent still sends user_signed_up with their user ID. analyticsChoice protects browser events, but the signup hook never receives that choice.
Was this helpful? React with 👍 or 👎 to provide feedback.
| function sanitizeSource(value: string): string | undefined { | ||
| const path = value.startsWith("/") ? value.split(/[?#]/, 1)[0] : ""; | ||
| if (!path || path.startsWith("//") || !isSafeAttributionValue(path)) return undefined; | ||
| return path; |
There was a problem hiding this comment.
|
| useEffect(() => { | ||
| previous.current = syncIdentifiedUser(posthog, userId, analyticsChoice(), previous.current); | ||
| }, [userId]); |
There was a problem hiding this comment.
Signed-in visits stay anonymous
On a fresh signed-in page load, IdentifyUser runs its effect before its parent PostHogProvider initializes PostHog. It calls identify before init, but syncIdentifiedUser still remembers the user as identified. The effect only depends on userId, so initialization never causes another attempt. Browser events remain anonymous instead of linking to the signup and purchase events.
Run identification after PostHog is ready, and only then remember the user. Correctness: 4/5.
| export function attributionFromSearch(url: URL): SignupAttribution { | ||
| const out: SignupAttribution = {}; | ||
| const source = sanitizeSource(url.pathname); | ||
| if (source && source !== "/") out.source = source; |
There was a problem hiding this comment.
An untagged first visit to / is discarded. With no UTM fields, hasAttribution is false and no cookie is saved. If the visitor then signs up from /sign-in, the server falls back to that page as source, not the original landing page. Keep / as a valid first-touch source so signup reports retain the home visit.
Correctness: 5/5.
| if (source && source !== "/") out.source = source; | |
| if (source) out.source = source; |
| (select a.provider_id from accounts a | ||
| where a.user_id = u.id order by a.created_at asc limit 1) as provider_id |
There was a problem hiding this comment.
Backfill mislabels email signups
The backfill treats the earliest currently linked account as the original signup method. Email-code signup creates no social account row, so an email user who later links GitHub is backfilled as a GitHub signup. Existing tests show this supported flow. Historical signup reports then count some email signups as social signups.
Use reliable signup history if available; otherwise report unknown rather than guessing from current connections. Correctness: 5/5.
| }; | ||
| if (event.insertId) properties.$insert_id = event.insertId; | ||
| try { | ||
| await fetch(`${config.host.replace(/\/$/, "")}/capture/`, { |
There was a problem hiding this comment.
Capture failures are invisible
Both server captureEvent helpers ignore HTTP failures and swallow network errors without a warning. If PostHog rejects the key or becomes unavailable, signup and purchase events can disappear while the application appears healthy. Maintainers have no warning that reports are incomplete.
Check the response status and record a redacted warning or failure count, without failing signup or billing. Correctness: 5/5.
| try { | ||
| document.cookie = optOut | ||
| ? `${ANALYTICS_CONSENT_COOKIE}=off; Path=/; Max-Age=${60 * 60 * 24 * 365}; SameSite=Lax` | ||
| : `${ANALYTICS_CONSENT_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`; |
There was a problem hiding this comment.
Previously saved opt-outs are not copied into the new convt_analytics cookie. A browser with convt:analytics-opt-out=1 from before this release still shows analytics as off, but the server allows capture when that cookie is missing. The new purchase-claim path then sends license_purchased despite the saved choice.
Copy the saved choice into the cookie before auth requests, and keep both stores in sync. Correctness: 5/5.
| const analytics = result.done === "processed" && "events" in result ? result.events : undefined; | ||
| return text(200, "ok", { | ||
| eventStatus: result.done, | ||
| drain: result.done === "processed", | ||
| analytics, |
There was a problem hiding this comment.
Replayed purchases go uncounted
processEvent now returns analytics events instead of sending them, but replayFailedEvents only checks res.status and drops res.analytics. A scheduled replay can therefore issue a license or start a trial without sending the matching PostHog event. Later scans do not recreate the first-issue event once the license exists.
Send the returned events from the replay path too, while keeping normal webhook capture in the background. Correctness: 5/5.
Knowledge Base Used:
Record user_signed_up once on account create (email and OAuth), identify the signed-in user in the browser, and capture desktop_trial_started and license_purchased from Polar ingest. A manual backfill script replays existing accounts with the original created_at. Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
Linux CI failed on renamed_binary_uses_convt_in_help_and_errors with Text file busy after fs::copy. Sync the copy and retry the exec. Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
The repo is public, so this PR must not introduce any address literals. Tests that need one now call testMailbox(), and staging PostHog vars sit above the existing alert address so git context does not pull it into the diff. Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
FlushFileBuffers needs write access, so File::open + sync_all failed with Access is denied after copying convt.exe. Fsync only on Unix and retry a denied first exec. Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
Server-side user_signed_up now skips DNT, GPC and the privacy opt-out cookie. A guest Desktop purchase emits license_purchased once when claim_purchases attaches it. captureEvent treats a non-2xx PostHog response as a failure, and the webhook returns those events so Polar is not waiting on capture. Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
Polar webhook capture now skips DNT, GPC and the privacy cookie, matching the site. OAuth user.create hooks see Better Auth's /callback/:id path, so signup_method comes from params.id or the request URL. Co-authored-by: Leo <leoisadev1@users.noreply.github.com>
33cd6b1 to
48c20f9
Compare
| "POSTHOG_KEY": "phc_yg96HDaDax6n2MmN7QyzvJjSh5qq2AwMUvaRnhmbJwMw", | ||
| "POSTHOG_HOST": "https://us.i.posthog.com", |
There was a problem hiding this comment.
Mock purchases inflate live counts
If local Wrangler keeps base vars when loading .dev.vars, the production POSTHOG_KEY stays enabled during dev:web. The generated billing .dev.vars never clears it, and readBillingEnv enables capture for any nonempty key. Local mock trials and purchases then reach the live PostHog project and inflate its counts.
Set POSTHOG_KEY= for local development, or disable capture for development and test environments. Correctness: 4/5.
Knowledge Base Used: Billing edge handler
| if (kind === "pro" && s.status === "trialing" && userId) | ||
| touched.events.push(desktopTrialStartedEvent(userId, s.providerSubscriptionId)); |
There was a problem hiding this comment.
desktop_trial_started fires only when the first stored subscription snapshot is trialing. If a converted subscription arrives first through a delayed delivery or recovery scan, it is inserted as active even though trialEndsAt still records the trial. The later, older trialing snapshot is skipped. That user's trial event is permanently missing from the signup-to-trial funnel.
Recover the event from retained trial history, using the same insertId, without restoring old subscription state. Correctness: 5/5.
Knowledge Base Used:
| await alert(ctx.db, ctx.clock(), "rejected_fact", `${source}:${subject}`, outcome.rejected); | ||
| ctx.log(`[billing] ${source}: rejected ${subject}: ${outcome.rejected}`); | ||
| } else { | ||
| await emitAnalytics(ctx.captureAnalytics, outcome.events); |
There was a problem hiding this comment.
Checkout sync ignores opt-outs
When a missing webhook makes the return page call syncCheckout, ingestFacts sends the trial or purchase events without checking DNT, GPC, or convt_analytics=off. The browser's analytics choice never reaches this path through checkoutResult, so users who opted out still get tracked.
Pass the allowed/off choice through the billing call and check it before capture. Correctness: 5/5.
Knowledge Base Used: Billing workflows and reconciliation
| return { | ||
| event: "license_purchased", | ||
| distinctId: userId, | ||
| insertId: `license_purchased:${plan}:${subjectId}`, | ||
| properties: { plan }, | ||
| }; |
There was a problem hiding this comment.
Both licensePurchasedEvent helpers omit timestamp, so captureEvent uses the send time when a recovery scan or later guest claim records an older purchase. A payment from last week can appear as today's sale and distort the reported time between signup and purchase.
Pass the saved payment time into both helpers. Correctness: 5/5.
Knowledge Base Used: Billing workflows and reconciliation
| export function isSafeAttributionValue(value: string): boolean { | ||
| return value.length > 0 && value.length <= 100 && !value.includes("@") && !/[\s<>]/.test(value); |
There was a problem hiding this comment.
Multi-word UTM values disappear
Valid values such as utm_campaign=fall%20launch and utm_term=file%20converter contain spaces after URLSearchParams reads them. isSafeAttributionValue rejects those strings, so signup reports lose the campaign and keyword fields.
Keep path checks separate and allow spaces in otherwise safe UTM values. Correctness: 5/5.
Adds the events needed to count users and follow signup → trial → purchase on the convt.app PostHog dashboard (id 2180880). Nothing in PostHog itself is changed by this PR.
The repo is public. This PR’s diff against main contains no email addresses — not even fixture ones. Tests that need a mailbox build it at runtime with
testMailbox(). PostHog events never send email as a property;distinct_idis always the user id.git diff origin/main...HEADwas scanned for address literals: 0 matches.Rebased onto main after #88.
Review follow-ups
user_signed_up. The Better Authuser.createhook skips capture when the request hasDNT: 1,Sec-GPC: 1, or theconvt_analytics=offcookie (the privacy-page switch writes that cookie as well as localStorage). Same signals the browser already uses.waitUntiling PostHog (apps/billing/src/index.ts). Polar’s own webhook request usually has none of those headers, so a paid Desktop or Pro event still records unless the delivery carries DNT, GPC, or the opt-out cookie.signup_method. Better Auth’s callback path is the pattern/callback/:id. The hook now readsparams.idand the request URL, so GitHub and Google signups are named instead ofunknown.license_purchasedfires once whenclaim_purchasesattaches a guest Desktop (or Pro) license, using the same$insert_idas a live Polar issuance (license_purchased:desktop:<order id>). Guest Polar orders whose verified account already exists (from Record $0 Desktop Polar orders and show them on Billing #88) are included in that claim path.captureEventhelpers treat a non-2xx PostHog response as a failure (and time out after 3s). Signup and billing still succeed; the error is logged without PII.handleWebhookno longer waits on PostHog after the billing commit. It returns the events on the result; the WorkerwaitUntils capture.What landed
user_signed_up(server) — fires exactly once from Better Auth’suser.createafter hook, for email codes, GitHub and Google, unless the request opted out.distinct_idis the user id. Properties:signup_method, first-touchsourcepath, and UTM fields when we have them. Email, name and other PII are stripped.$insert_idisuser_signed_up:<user id>.posthog.identify(userId)(client) — on sign-in / signed-in page load, only the user id.posthog.reset()on sign-out. Respects GPC, DNT and the privacy-page opt-out.desktop_trial_startedandlicense_purchased(Polar ingest) — after the webhook (or reconciler) transaction commits. Guest Desktop buys wait until claim. Staging still has an emptyPOSTHOG_KEY.Backfill — manual, not in CI:
Insights to add on dashboard 2180880
user_signed_up, cumulative / all time.user_signed_up, day interval. Optional breakdown:signup_method.user_signed_up→desktop_trial_started→license_purchased.PR #82 owns the privacy disclosure. This PR does not edit
privacy.tsx.Do not merge from here; a reviewer will merge.