Skip to content

Track signups and license purchases in PostHog - #89

Merged
leoisadev1 merged 9 commits into
mainfrom
cursor/posthog-signup-tracking-2c93
Oct 8, 2026
Merged

leoisadev1 merged 9 commits into
mainfrom
cursor/posthog-signup-tracking-2c93

Conversation

@leoisadev1

@leoisadev1 leoisadev1 commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

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_id is always the user id. git diff origin/main...HEAD was scanned for address literals: 0 matches.

Rebased onto main after #88.

Review follow-ups

  • Consent on user_signed_up. The Better Auth user.create hook skips capture when the request has DNT: 1, Sec-GPC: 1, or the convt_analytics=off cookie (the privacy-page switch writes that cookie as well as localStorage). Same signals the browser already uses.
  • Consent on Polar purchase events. The billing Worker applies that same gate before 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.
  • OAuth signup_method. Better Auth’s callback path is the pattern /callback/:id. The hook now reads params.id and the request URL, so GitHub and Google signups are named instead of unknown.
  • Claimed guest purchases. license_purchased fires once when claim_purchases attaches a guest Desktop (or Pro) license, using the same $insert_id as 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.
  • Capture failures. Both captureEvent helpers 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.
  • Webhook latency. handleWebhook no longer waits on PostHog after the billing commit. It returns the events on the result; the Worker waitUntils capture.

What landed

  1. user_signed_up (server) — fires exactly once from Better Auth’s user.create after hook, for email codes, GitHub and Google, unless the request opted out. distinct_id is the user id. Properties: signup_method, first-touch source path, and UTM fields when we have them. Email, name and other PII are stripped. $insert_id is user_signed_up:<user id>.

  2. 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.

  3. desktop_trial_started and license_purchased (Polar ingest) — after the webhook (or reconciler) transaction commits. Guest Desktop buys wait until claim. Staging still has an empty POSTHOG_KEY.

  4. Backfill — manual, not in CI:

    bun run analytics:backfill-signups            # dry-run against this checkout
    bun run analytics:backfill-signups --send     # needs POSTHOG_KEY

Insights to add on dashboard 2180880

  1. Total users — unique users, event user_signed_up, cumulative / all time.
  2. Signups per day — Trends, user_signed_up, day interval. Optional breakdown: signup_method.
  3. Signup → trial → purchase — Funnel: 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.

Open in Web Open in Cursor 

@leoisadev1
leoisadev1 marked this pull request as ready for review October 7, 2026 21:51

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 9 potential issues.

Devin Review

returning id`,
);
if (inserted.length === 0) return;
if (o.user_id) events.push(licensePurchasedEvent(o.user_id, "desktop", o.id));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread packages/billing/src/analytics.ts Outdated
Comment on lines +62 to +72
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(),
}),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Devin Review


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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +89 to +91
useEffect(() => {
rememberAttribution();
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +456 to +457
if (kind === "pro" && s.status === "trialing" && userId)
touched.events.push(desktopTrialStartedEvent(userId, s.providerSubscriptionId));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +65 to +66
const method =
row.provider_id === "github" || row.provider_id === "google" ? row.provider_id : "email";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Backfill mislabels linked accounts

An email-code user who later links GitHub or Google is labeled a social signup. The earliest linked identity does not establish the original signup method.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +38 to +39
const [config, session] = await Promise.all([getPublicConfig(), getSession()]);
return { posthog: config.posthog, userId: session?.user.id ?? null };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Root session lookup broadens database dependence

Every public route now awaits getSession before rendering. A database outage can affect pages that previously needed only public configuration.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread apps/web/src/server/auth.ts Outdated
Comment on lines +422 to +424
scope.background(
capture(signupEventFromAuthHook(user.id, hookCtx as AuthHookContext, env.authUrl)),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +32 to +35
function sanitizeSource(value: string): string | undefined {
const path = value.startsWith("/") ? value.split(/[?#]/, 1)[0] : "";
if (!path || path.startsWith("//") || !isSafeAttributionValue(path)) return undefined;
return path;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Private path segments enter signup analytics

When a landing URL contains an identifier in its path, sanitizeSource preserves it in the attribution cookie. Signup capture later sends that path to PostHog as source.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@greptile-apps

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 0/5 Tier: plus

[Medium risk] Adds PostHog event capture for signups and license purchases.

The PR should not merge yet; checkout sync ignores opt-outs, and several earlier blocking findings remain unfixed.

Fix All in Claude CodeFindings

  1. P1 Checkout sync ignores opt-outs ▶
  2. P1 Signed-in visits stay anonymous ▶
  3. P1 Saved opt-outs are ignored ▶
  4. P1 Replayed purchases go uncounted ▶
  5. P1 Mock purchases inflate live counts ▶
  6. P1 Recovered trials go uncounted ▶
  7. P2 Purchases get the wrong date ▶
  8. P2 Multi-word UTM values disappear ▶
  9. P2 Home visits get misattributed ▶
  10. P2 Backfill mislabels email signups ▶
  11. P2 Capture failures are invisible ▶

Summary

Adds account-linked signup, trial, and purchase events to PostHog, plus browser identification and a manual signup backfill.

  • Checkout sync still skips the browser's analytics choice.
  • Delayed purchases receive the send date, and UTM values containing spaces are dropped.
  • leoisadev1 explicitly accepts capturing Polar events when delivery headers lack opt-out signals because Polar normally sends none.
  • leoisadev1 keeps capture best-effort so analytics failures do not fail signup or billing, and assigns privacy disclosure to PR feat(telemetry): report desktop and web exceptions to PostHog #82.
  • Tests and builds were not run.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Account created] --> B[Check request consent]
  B --> C[Background signup capture]
  C --> P[PostHog]
  W[Verified Polar webhook] --> I[Apply billing facts]
  S[Checkout sync or recovery scan] --> I
  I --> D[Commit billing changes]
  D --> E{Entry path}
  E -->|Webhook| H[Check delivery headers]
  H --> G[Worker background capture]
  G --> P
  E -->|Other ingest caller| P
Loading

Reviews (7) · Last reviewed commit: "Merge branch 'main' into cursor/posthog-..." · Reviewed by Greptile

Comment thread crates/convt-cli/tests/conversions.rs Outdated
Comment on lines +10 to +12
useEffect(() => {
previous.current = syncIdentifiedUser(posthog, userId, analyticsChoice(), previous.current);
}, [userId]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Fix in Claude Code

Comment thread packages/billing/src/converge.ts
export function attributionFromSearch(url: URL): SignupAttribution {
const out: SignupAttribution = {};
const source = sanitizeSource(url.pathname);
if (source && source !== "/") out.source = source;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Home visits get misattributed

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.

Suggested change
if (source && source !== "/") out.source = source;
if (source) out.source = source;

Fix in Claude Code

Comment on lines +58 to +59
(select a.provider_id from accounts a
where a.user_id = u.id order by a.created_at asc limit 1) as provider_id

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Fix in Claude Code

Comment thread packages/billing/src/webhook.ts Outdated
Comment thread packages/billing/src/analytics.ts Outdated
};
if (event.insertId) properties.$insert_id = event.insertId;
try {
await fetch(`${config.host.replace(/\/$/, "")}/capture/`, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Fix in Claude Code

Comment on lines +45 to +48
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`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Saved opt-outs are ignored

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.

Fix in Claude Code

Comment on lines +131 to +135
const analytics = result.done === "processed" && "events" in result ? result.events : undefined;
return text(200, "ok", {
eventStatus: result.done,
drain: result.done === "processed",
analytics,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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:

Fix in Claude Code

cursoragent and others added 8 commits October 8, 2026 00:40
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>
@cursor
cursor Bot force-pushed the cursor/posthog-signup-tracking-2c93 branch from 33cd6b1 to 48c20f9 Compare October 8, 2026 00:41
Comment on lines +23 to +24
"POSTHOG_KEY": "phc_yg96HDaDax6n2MmN7QyzvJjSh5qq2AwMUvaRnhmbJwMw",
"POSTHOG_HOST": "https://us.i.posthog.com",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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

Fix in Claude Code

Comment on lines +463 to +464
if (kind === "pro" && s.status === "trialing" && userId)
touched.events.push(desktopTrialStartedEvent(userId, s.providerSubscriptionId));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Recovered trials go uncounted

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:

Fix in Claude Code

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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

Fix in Claude Code

Comment on lines +19 to +24
return {
event: "license_purchased",
distinctId: userId,
insertId: `license_purchased:${plan}:${subjectId}`,
properties: { plan },
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Purchases get the wrong date

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

Fix in Claude Code

Comment on lines +53 to +54
export function isSafeAttributionValue(value: string): boolean {
return value.length > 0 && value.length <= 100 && !value.includes("@") && !/[\s<>]/.test(value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Fix in Claude Code

@leoisadev1
leoisadev1 merged commit 5e6026a into main Oct 8, 2026
10 checks passed
@leoisadev1
leoisadev1 deleted the cursor/posthog-signup-tracking-2c93 branch October 8, 2026 01:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants